diff options
| author | Agando <roduccomm@gmail.com> | 2026-11-02 20:46:18 +0100 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-11-02 20:46:18 +0100 |
| commit | 8d5c4b563da5225c4dbd2a44c7635c49a16d5ab1 (patch) | |
| tree | de05cf1dcda3b45f131e4b1fca85421b49facdd6 | |
| parent | 89787327ec5aba15d11ca7bff2b4fb7f0df24a79 (diff) | |
feat(newos4): configurable tool budgets, per-model defaults, real tool telemetry
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).
- read_file's schema no longer promises a number the model class may not give.
Telemetry:
- _recordToolCall keeps sizes, duration, ok and a 120-char arg preview instead
of the whole payload; ai-stats prints calls / ok% / avg ms / KB / truncation
from localStorage webx11_tool_stats.
Server (api/proxy-api.py):
- /api/extract honours max_chars (default 12000, cap 100000) and returns
content_chars + truncated; the bs4 branch is no longer uncapped while the
regex fallback was silently cut at 10 000.
BEHAVIOUR CHANGE: num_ctx for a model with no explicit setting now follows the
class default (16384 -> 65536 for cloud/large, 8192 for tiny). Cloud models bill
on tokens used, so cost is unchanged; local models get a bigger KV cache.
Two build traps, both now documented in AGENTS.md:
- model-profiles.js must be the FIRST entry in CORE_FILES and must export the
MODEL_PROFILES global (a TDZ typeof throws; without the global every budget
silently falls back to the code default).
- a budget key needs the setter branch AND the constructor loader at
module-aiassist.js:49 - persisting without restoring looks fine until reload.
Verified: test-tool-context-bound 23/23 (RED before: E1-E4 + TypeError on
_toolLimit); live bundle read_page 32000 / result 12000 / budget 48000 /
fetch 12000 and _ctxTokens 65536 for gemma4:31b-cloud; /api/extract 28970 ->
500 chars with truncated true; ai-config write+restore probe 6/6.
| -rw-r--r-- | help.html | 3 | ||||
| -rw-r--r-- | main.html | 6 | ||||
| -rw-r--r-- | modules-index.json | 4 | ||||
| -rw-r--r-- | modules/core/core-bundle.js | 205 | ||||
| -rw-r--r-- | modules/core/model-profiles.js | 43 | ||||
| -rw-r--r-- | modules/core/module-aiassist.js | 151 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 2 | ||||
| -rw-r--r-- | modules/core/ollama-manager.js | 58 | ||||
| -rw-r--r-- | modules/test/test-tool-context-bound.js | 46 | ||||
| -rw-r--r-- | scripts/rebuild-bundles.py | 2 |
10 files changed, 462 insertions, 58 deletions
@@ -539,7 +539,8 @@ diff a.txt b.txt</pre> <tr><td><code>ai-deny</code></td><td>Deny pending destructive action</td><td><code>ai-deny</code></td></tr> <tr><td><code>ai-stop</code></td><td>Cancel running AI request</td><td><code>ai-stop</code></td></tr> <tr><td><code>ai-compress</code></td><td>Manually compress conversation context</td><td><code>ai-compress</code></td></tr> - <tr><td><code>ai-config</code></td><td>View/set AI loop config (max-turns, max-context, max-context-tokens, confirm-timeout(0=disabled), chat-lock-timeout, skill-debounce, trivial-optimization)</td><td><code>ai-config max-turns 12</code></td></tr> + <tr><td><code>ai-config</code></td><td>View/set AI loop config (max-turns, max-context, max-context-tokens, confirm-timeout(0=disabled), chat-lock-timeout, skill-debounce, trivial-optimization, temperature, top-p, max-tokens, thinking) and AI tool budgets in characters (read-page-chars, read-result-chars, tool-context-budget, fetch-chars — each 500-200000; read-page-chars/read-result-chars use the model default when unset)</td><td><code>ai-config max-turns 12</code></td></tr> + <tr><td><code>ai-stats</code></td><td>Per-tool telemetry for this browser (localStorage <code>webx11_tool_stats</code>): calls, ok %, avg duration, KB returned, truncation rate — for finding which tools flood the AI context</td><td><code>ai-stats</code></td></tr> <tr><td><code>debug</code></td><td>Toggle AI debug mode (ext module)</td><td><code>debug</code></td></tr> <tr><td><code>debug-ai</code></td><td>Toggle AI tool-call debug panel</td><td><code>debug-ai</code></td></tr> <tr><td><code>debug-ai-last</code></td><td>Show last AI response (content, tool calls, errors)</td><td><code>debug-ai-last</code></td></tr> @@ -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.136"></script> +<script src="modules/core/core-bundle.js?v=v4.6.137"></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.136"></script> +<script src="modules/core/module-loader.js?v=v4.6.137"></script> <!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work --> -<script src="modules/ext/critical-ext-bundle.js?v=v4.6.136"></script> +<script src="modules/ext/critical-ext-bundle.js?v=v4.6.137"></script> <script> // ═══ ONE TRUE VERSION SOURCE ═══ diff --git a/modules-index.json b/modules-index.json index fbc2f8c..81f23af 100644 --- a/modules-index.json +++ b/modules-index.json @@ -1,5 +1,5 @@ { - "version": "2026-10-29-atlas", + "version": "2026-11-02-atlas", "generated": "2026-06-30T00:00:00.000Z", "modules": { "module-agent-registry": { @@ -64,6 +64,7 @@ "ai-deny", "ai-ignore", "ai-model", + "ai-stats", "ai-stop", "ai-system", "archive-list", @@ -756,6 +757,7 @@ "script", "set", "sh", + "test", "unset" ], "description": "── Hook into terminal.executeCommand ──", diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index e845903..56f0c97 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -1,4 +1,49 @@ // Core module bundle// Generated from /var/www/webx11.duckdns.org/spaces/newos4/modules/core +/* === model-profiles.js === */ +// modules/core/model-profiles.js — per-model-class tool budgets. +// class is picked by an ordered rule list; the FIRST match wins, so keep specific ids above size rules. +const MODEL_CLASSES = { + tiny: { ctx: 8192, readPageChars: 4000, toolResultChars: 3000, toolContextBudgetChars: 12000, fetchChars: 3000 }, + small: { ctx: 16384, readPageChars: 8000, toolResultChars: 4000, toolContextBudgetChars: 16000, fetchChars: 4000 }, + mid: { ctx: 32768, readPageChars: 16000, toolResultChars: 6000, toolContextBudgetChars: 24000, fetchChars: 6000 }, + large: { ctx: 65536, readPageChars: 32000, toolResultChars: 12000, toolContextBudgetChars: 48000, fetchChars: 12000 }, +}; + +// Ordered: first match wins. Only ids we know; everything else falls through to the size parse. +const MODEL_IDS = { + 'gemma4:31b-cloud': 'large', + 'qwen3:0.6b': 'tiny', + 'gemma2:2b': 'tiny', + 'llama3.1:8b': 'small', + 'gemma4:31b': 'mid', +}; + +// "235b" -> large, "31b" -> mid, "8b" -> small, "0.6b"/"2b" -> tiny +function classFor(modelId) { + const id = String(modelId || '').toLowerCase(); + if (!id) return 'mid'; + if (MODEL_IDS[id]) return MODEL_IDS[id]; + if (id.endsWith('-cloud') || id.includes(':cloud')) return 'large'; + const m = id.match(/(\d+(?:\.\d+)?)\s*b\b/); + if (!m) return 'mid'; + const p = parseFloat(m[1]); + if (p <= 3) return 'tiny'; + if (p <= 9) return 'small'; + if (p <= 32) return 'mid'; + return 'large'; +} + +function profileFor(modelId) { + return MODEL_CLASSES[classFor(modelId)] || MODEL_CLASSES.mid; +} + +// The bundle concatenates this file FIRST, so this binding exists before any module method runs. +// module-aiassist/ollama-manager resolve budgets through it: a require() there resolved relative +// to whoever called them and always returned null (found by the live ai-config probe 2026-11-02). +const MODEL_PROFILES = { MODEL_CLASSES, classFor, profileFor }; + +if (typeof module !== 'undefined' && module.exports) module.exports = { MODEL_CLASSES, classFor, profileFor, MODEL_PROFILES }; + /* === virtual-fs.js === */ class VirtualFileSystem { constructor() { @@ -1321,7 +1366,14 @@ class OllamaManager { // just told. Send it on every Ollama request; the OpenAI-compatible branch has its own params. _ctxTokens() { const n = Number(this.numCtx || (typeof localStorage !== 'undefined' ? localStorage.getItem('webx11_ollama_numctx') : 0)); - return Number.isFinite(n) && n >= 512 ? Math.floor(n) : 16384; + if (Number.isFinite(n) && n >= 512) return Math.floor(n); + // No explicit setting: take the model class default (large/cloud 65536, mid 32768, …) + try { + const mp = this.modelProfiles + || (typeof MODEL_PROFILES !== 'undefined' ? MODEL_PROFILES : null); + if (mp && mp.profileFor && this.currentModel) return mp.profileFor(this.currentModel).ctx; + } catch (e) {} + return 16384; } _ollamaOptions(extra) { @@ -8869,7 +8921,7 @@ class ServerMonitorModule { this._loopProfiles = {}; this._initLoopProfiles(); // Restore AI config from localStorage (maxContext, MAX_TOOL_TURNS, etc.) - try { const cfg = localStorage.getItem('webx11_ai_config'); if (cfg) { const p = JSON.parse(cfg); if (p.maxTurns) this.MAX_TOOL_TURNS = p.maxTurns; if (p.maxContext) this.maxContext = p.maxContext; if (p.maxContextTokens) this.maxContextTokens = p.maxContextTokens; if (p.confirmTimeoutMs) this._confirmTimeoutMs = p.confirmTimeoutMs; if (p.skillDebounceMs) this._skillDebounceMs = p.skillDebounceMs; if (p.chatLockTimeoutMs) this._chatLockTimeoutMs = p.chatLockTimeoutMs; } } catch (e) {} + try { const cfg = localStorage.getItem('webx11_ai_config'); if (cfg) { const p = JSON.parse(cfg); if (p.maxTurns) this.MAX_TOOL_TURNS = p.maxTurns; if (p.maxContext) this.maxContext = p.maxContext; if (p.maxContextTokens) this.maxContextTokens = p.maxContextTokens; if (p.confirmTimeoutMs) this._confirmTimeoutMs = p.confirmTimeoutMs; if (p.skillDebounceMs) this._skillDebounceMs = p.skillDebounceMs; if (p.chatLockTimeoutMs) this._chatLockTimeoutMs = p.chatLockTimeoutMs; if (p.maxReadPageChars) this.maxReadPageChars = p.maxReadPageChars; if (p.maxToolResultChars) this.maxToolResultChars = p.maxToolResultChars; if (p.toolContextBudgetChars) this.toolContextBudgetChars = p.toolContextBudgetChars; if (p.maxFetchChars) this.maxFetchChars = p.maxFetchChars; } } catch (e) {} try { const s = localStorage.getItem('webx11_ai_patterns'); if (s) this._commandFreq = JSON.parse(s); } catch (e) {} try { const p = localStorage.getItem('webx11_user_profile'); if (p) this._userProfile = JSON.parse(p); } catch (e) {} // Restore AI conversation context from localStorage @@ -8985,7 +9037,7 @@ buildSystemPrompt() { parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path' }, offset: { type: 'integer', description: 'Start character offset (default 0)' }, - limit: { type: 'integer', description: 'Max characters to return (default 8000, max 32000)' } + limit: { type: 'integer', description: 'Max characters per page (default 8000). The effective page size follows the current model\'s budget and is capped again before the result enters context — the response carries returned_chars/total_chars/truncated/next_offset, page on those' } }, required: ['path'] } } }, { @@ -9048,7 +9100,8 @@ buildSystemPrompt() { url: { type: 'string', description: 'Full URL' }, 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)' } + 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)' }, }, required: ['url'] } @@ -9578,6 +9631,7 @@ buildSystemPrompt() { this.commands['ai-model'] = (args) => this.showModel(args); this.commands['ai-compress'] = () => this._manualCompress(); this.commands['ai-config'] = (args) => this.aiConfig(args); + this.commands['ai-stats'] = () => this.aiStats(); this.commands['commit-approve'] = (args) => this._executeCommit(args.join(' ')); this.commands['commit-deny'] = () => { if (!this._pendingCommit) return this.terminal.print('No pending commit.', 'warning'); @@ -9838,7 +9892,10 @@ buildSystemPrompt() { this.terminal.log('INFO', 'AI', 'Query: ' + userMessage.substring(0, 100)); if (loopResult && loopResult.toolTurns) { const toolNames = this._toolCallLog.slice(-(loopResult.toolTurns || 0)).map(function(e) { return e.name; }).join(', '); - this.terminal.log('INFO', 'AI', 'Used ' + loopResult.toolTurns + ' tool(s): ' + toolNames); + this.terminal.log('INFO', 'AI', 'Used ' + loopResult.toolTurns + ' tool(s): ' + toolNames + + ' · returned ' + Math.round(recent.reduce((a, e) => a + e.returnedBytes, 0) / 1024) + 'KB' + + ' · clipped ' + Math.round(recent.reduce((a, e) => a + e.clippedBytes, 0) / 1024) + 'KB' + + ' · ' + recent.filter(e => e.truncated).length + ' truncated'); } } // Auto-archive every 10 messages @@ -10167,10 +10224,87 @@ buildSystemPrompt() { // went 6 → 112 705 tokens across 8 model calls (maxContextTokens is 12 000). Two // limits keep a single turn inside the window: clip each observation as it arrives, // and elide the oldest ones once the running total passes the budget. + _recordToolCall(entry) { + const r = String(entry.result || ''); + let truncated = false, contentChars = null; + try { + if (r.charAt(0) === '{') { + const o = JSON.parse(r); + truncated = !!(o.truncated || o.clipped); + contentChars = (typeof o.content_chars === 'number') ? o.content_chars : null; + } + } catch (e) {} + const rec = { + turn: entry.turn, name: entry.name, + argsPreview: String(entry.args || '').slice(0, 120), + argsBytes: String(entry.args || '').length, + resultBytes: r.length, contentChars: contentChars, + returnedBytes: Number(entry.returnedBytes || r.length), + clippedBytes: Math.max(0, r.length - Number(entry.returnedBytes || r.length)), + truncated: truncated, durationMs: entry.durationMs || 0, + ok: !entry.error, errorKind: entry.errorKind || null, + }; + this._toolCallLog.push(rec); + if (this._toolCallLog.length > 50) this._toolCallLog.shift(); + this._toolStatsAdd(rec); + return rec; + } + _toolStatsAdd(rec) { + try { + const K = 'webx11_tool_stats'; + const s = JSON.parse(localStorage.getItem(K) || '{"v":1,"tools":{}}'); + const t = s.tools[rec.name] || (s.tools[rec.name] = { calls: 0, ok: 0, ms: 0, bytes: 0, truncated: 0 }); + t.calls++; t.ok += rec.ok ? 1 : 0; t.ms += rec.durationMs; + t.bytes += rec.returnedBytes; t.truncated += rec.truncated ? 1 : 0; + localStorage.setItem(K, JSON.stringify(s)); + } catch (e) {} + } + // ── Tool budgets: call arg > loop profile > model profile > ai-config > code default ── + // ── Tool budgets: call arg > loop profile > model profile > ai-config > code default ── + _modelProfile() { + try { + // `this.modelProfiles` is set by the node harness; in the browser the bundle + // supplies the top-level MODEL_PROFILES constant. Never require() a sibling here — + // the path resolves relative to whoever called the module (found the hard way). + const mp = this.modelProfiles + || (typeof MODEL_PROFILES !== 'undefined' ? MODEL_PROFILES : null); + const id = this.terminal && this.terminal.ollamaManager + ? this.terminal.ollamaManager.currentModel : ''; + return mp && mp.profileFor ? mp.profileFor(id) : null; + } catch (e) { return null; } + } + _toolLimit(key, callArg) { + const explicit = { readPageChars: this.maxReadPageChars, toolResultChars: this.maxToolResultChars, + toolContextBudgetChars: this.toolContextBudgetChars, fetchChars: this.maxFetchChars }[key]; + const prof = this._modelProfile() || {}; + const codeDefault = { readPageChars: 16000, toolResultChars: 6000, + toolContextBudgetChars: 24000, fetchChars: 4000 }[key]; + if (Number(callArg) > 0) return Number(callArg); + if (Number(explicit) > 0) return Number(explicit); + if (Number(prof[key]) > 0) return Number(prof[key]); + return codeDefault; + } _clipToolResult(result) { + const max = this._toolLimit('toolResultChars'); const text = typeof result === 'string' ? result : JSON.stringify(result); - const max = this.maxToolResultChars || 6000; if (!text || text.length <= max) return text; + // A tool envelope is JSON with a `content` field. Clipping the envelope blind deletes + // `truncated` / `next_offset`, so paging loses its continuation pointer (measured + // 2026-11-02: a 16 000-char page serialises to ~16 080 chars, cut at 6 000). + if (text.charAt(0) === '{') { + try { + const obj = JSON.parse(text); + if (obj && typeof obj.content === 'string' && obj.content.length > 0) { + const keep = Math.max(500, max - 200); // headroom for the other keys + obj.returned_chars = keep; + obj.content = obj.content.slice(0, keep) + + '\n…[clipped: ' + (obj.content.length - keep) + ' of ' + obj.content.length + + ' chars withheld — re-read with offset/limit if you need the rest]'; + obj.clipped = true; + return JSON.stringify(obj); + } + } catch (e) { /* not JSON → fall through to the string clip */ } + } return text.slice(0, max) + '\n…[clipped: ' + (text.length - max) + ' of ' + text.length + ' chars withheld — re-read with offset/limit if you need the rest]'; @@ -10559,15 +10693,9 @@ buildSystemPrompt() { toolResult = await this.runTool(fn.name, fn.arguments); this._cacheSet(fn.name, _cg.key, toolResult); } - const t1 = Math.round((Date.now() - t0) / 1000); - this._toolCallLog.push({ - turn: toolTurns, - name: fn.name, - args: fn.arguments, - result: toolResult, - duration: t1, - error: this._isToolError(toolResult) ? true : false - }); + const t1 = Date.now() - t0; + this._recordToolCall({ turn: toolTurns, name: fn.name, args: fn.arguments, + result: toolResult, durationMs: t1, error: this._isToolError(toolResult) }); _loopLog.push(this._toolCallLog[this._toolCallLog.length - 1]); // Auto-trim to 50 — prevents unbounded memory growth if (this._toolCallLog.length > 50) { @@ -11298,7 +11426,8 @@ buildSystemPrompt() { // resume. Now the caller pages with offset/limit and gets next_offset. const total = content.length; let offset = Math.max(0, Math.min(parseInt(a.offset, 10) || 0, total)); - const limit = Math.max(500, Math.min(parseInt(a.limit, 10) || 8000, 16000)); + const pageCap = Math.max(500, Math.min(this._toolLimit('readPageChars'), 200000)); + const limit = Math.max(500, Math.min(parseInt(a.limit, 10) || 8000, pageCap)); // Never split a surrogate pair (emoji/CJK-ext) across pages: // a lone surrogate makes JSON.stringify emit an invalid body. if (offset > 0) { @@ -11401,8 +11530,9 @@ buildSystemPrompt() { 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)); const endpoint = method === 'POST' ? '/api/fetch' : '/api/extract'; - const params = new URLSearchParams({ url }); + const params = new URLSearchParams({ url, max_chars: String(maxChars) }); 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' }); @@ -11413,10 +11543,15 @@ buildSystemPrompt() { // For POST/route, the response is raw text; for GET/extract it's JSON if (method === 'POST') { const text = await resp.text(); - return JSON.stringify({ method, content: text.substring(0, 4000), content_length: text.length }); + 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 data = await resp.json(); - return JSON.stringify({ method, title: data.title || '', content: (data.content || '').substring(0, 4000), word_count: data.word_count || 0, method_proxy: data.method || 'extract' }); + 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' }); } catch (e) { return JSON.stringify({ error: 'Fetch failed: ' + e.message }); } } case 'wpm_install': { @@ -12690,12 +12825,30 @@ buildSystemPrompt() { _getAiConfigKeys() { return ['max-turns', 'max-context', 'max-context-tokens', 'confirm-timeout', 'chat-lock-timeout', 'skill-debounce', 'trivial-optimization', 'temperature', 'top-p', 'max-tokens', 'thinking']; } + aiStats() { + let s = { tools: {} }; + try { s = JSON.parse(localStorage.getItem('webx11_tool_stats') || '{"tools":{}}'); } catch (e) {} + const rows = Object.keys(s.tools || {}).sort((a, b) => s.tools[b].calls - s.tools[a].calls); + if (!rows.length) return this.terminal.print('ai-stats: no tool calls recorded yet', 'info'); + this.terminal.print('=== Tool telemetry (this browser) ===', 'info'); + for (const n of rows) { + const t = s.tools[n]; + this.terminal.print(`${n}: ${t.calls} calls · ok ${Math.round(100 * t.ok / t.calls)}% · ` + + `avg ${Math.round(t.ms / t.calls)}ms · returned ${(t.bytes / 1024).toFixed(1)}KB · ` + + `truncated ${t.truncated} (${Math.round(100 * t.truncated / t.calls)}%)`, ''); + } + this.terminal.print('ai-stats reset — clear localStorage webx11_tool_stats', 'info'); + } aiConfig(args) { if (args.length === 0) { this.terminal.print('=== AI Config ===', 'info'); this.terminal.print(`max-turns: ${this.MAX_TOOL_TURNS} (default: 8)`, ''); this.terminal.print(`max-context: ${this.maxContext} (default: 10)`, ''); this.terminal.print(`max-context-tokens: ${this.maxContextTokens} (code default: 12000)`, ''); +this.terminal.print(`read-page-chars: ${this._toolLimit('readPageChars')} (model default applied; code default 16000)`, ''); +this.terminal.print(`read-result-chars: ${this._toolLimit('toolResultChars')} (model default applied; code default 6000)`, ''); +this.terminal.print(`tool-context-budget: ${this._toolLimit('toolContextBudgetChars')} (code default 24000)`, ''); +this.terminal.print(`fetch-chars: ${this._toolLimit('fetchChars')} (code default 4000)`, ''); this.terminal.print(`trivial-optimization: ${(this.terminal?.ollamaManager?.optimizationEnabled) ? 'on' : 'off (default)'}`, ''); this.terminal.print(`confirm-timeout: ${this._confirmTimeoutMs > 0 ? this._confirmTimeoutMs / 1000 + 's' : 'disabled (no auto-deny)'} (default: 120s)`, ''); this.terminal.print(`chat-lock-timeout: ${this._chatLockTimeoutMs / 1000}s (default: 10s)`, ''); @@ -12761,6 +12914,14 @@ buildSystemPrompt() { if (!validModes.includes(val)) return this.terminal.print(`thinking must be one of: ${validModes.join(', ')}`, 'warning'); const om = this.terminal?.ollamaManager; if (om && om.modelParams) { om.modelParams.thinking = val; om._saveModelParams(); } +} else if (key === 'read-page-chars' || key === 'read-result-chars' + || key === 'tool-context-budget' || key === 'fetch-chars') { + const n = parseInt(val, 10); + if (isNaN(n) || n < 500 || n > 200000) return this.terminal.print(key + ' must be 500-200000', 'warning'); + if (key === 'read-page-chars') this.maxReadPageChars = n; + if (key === 'read-result-chars') this.maxToolResultChars = n; + if (key === 'tool-context-budget') this.toolContextBudgetChars = n; + if (key === 'fetch-chars') this.maxFetchChars = n; } else { return this.terminal.print(`Unknown key: ${key}. Valid: max-turns, max-context, max-context-tokens, confirm-timeout, chat-lock-timeout, skill-debounce, trivial-optimization, temperature, top-p, max-tokens, thinking`, 'warning'); } @@ -12771,7 +12932,11 @@ buildSystemPrompt() { maxContextTokens: this.maxContextTokens, confirmTimeoutMs: this._confirmTimeoutMs, chatLockTimeoutMs: this._chatLockTimeoutMs, - skillDebounceMs: this._skillDebounceMs + skillDebounceMs: this._skillDebounceMs, +maxReadPageChars: this.maxReadPageChars, +maxToolResultChars: this.maxToolResultChars, +toolContextBudgetChars: this.toolContextBudgetChars, +maxFetchChars: this.maxFetchChars, })); this.terminal.print(`ai-config ${key} set to ${val}`, 'success'); } diff --git a/modules/core/model-profiles.js b/modules/core/model-profiles.js new file mode 100644 index 0000000..593378d --- /dev/null +++ b/modules/core/model-profiles.js @@ -0,0 +1,43 @@ +// modules/core/model-profiles.js — per-model-class tool budgets. +// class is picked by an ordered rule list; the FIRST match wins, so keep specific ids above size rules. +const MODEL_CLASSES = { + tiny: { ctx: 8192, readPageChars: 4000, toolResultChars: 3000, toolContextBudgetChars: 12000, fetchChars: 3000 }, + small: { ctx: 16384, readPageChars: 8000, toolResultChars: 4000, toolContextBudgetChars: 16000, fetchChars: 4000 }, + mid: { ctx: 32768, readPageChars: 16000, toolResultChars: 6000, toolContextBudgetChars: 24000, fetchChars: 6000 }, + large: { ctx: 65536, readPageChars: 32000, toolResultChars: 12000, toolContextBudgetChars: 48000, fetchChars: 12000 }, +}; + +// Ordered: first match wins. Only ids we know; everything else falls through to the size parse. +const MODEL_IDS = { + 'gemma4:31b-cloud': 'large', + 'qwen3:0.6b': 'tiny', + 'gemma2:2b': 'tiny', + 'llama3.1:8b': 'small', + 'gemma4:31b': 'mid', +}; + +// "235b" -> large, "31b" -> mid, "8b" -> small, "0.6b"/"2b" -> tiny +function classFor(modelId) { + const id = String(modelId || '').toLowerCase(); + if (!id) return 'mid'; + if (MODEL_IDS[id]) return MODEL_IDS[id]; + if (id.endsWith('-cloud') || id.includes(':cloud')) return 'large'; + const m = id.match(/(\d+(?:\.\d+)?)\s*b\b/); + if (!m) return 'mid'; + const p = parseFloat(m[1]); + if (p <= 3) return 'tiny'; + if (p <= 9) return 'small'; + if (p <= 32) return 'mid'; + return 'large'; +} + +function profileFor(modelId) { + return MODEL_CLASSES[classFor(modelId)] || MODEL_CLASSES.mid; +} + +// The bundle concatenates this file FIRST, so this binding exists before any module method runs. +// module-aiassist/ollama-manager resolve budgets through it: a require() there resolved relative +// to whoever called them and always returned null (found by the live ai-config probe 2026-11-02). +const MODEL_PROFILES = { MODEL_CLASSES, classFor, profileFor }; + +if (typeof module !== 'undefined' && module.exports) module.exports = { MODEL_CLASSES, classFor, profileFor, MODEL_PROFILES }; diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js index e4b7b7e..365bf94 100644 --- a/modules/core/module-aiassist.js +++ b/modules/core/module-aiassist.js @@ -46,7 +46,7 @@ this._loopProfiles = {}; this._initLoopProfiles(); // Restore AI config from localStorage (maxContext, MAX_TOOL_TURNS, etc.) - try { const cfg = localStorage.getItem('webx11_ai_config'); if (cfg) { const p = JSON.parse(cfg); if (p.maxTurns) this.MAX_TOOL_TURNS = p.maxTurns; if (p.maxContext) this.maxContext = p.maxContext; if (p.maxContextTokens) this.maxContextTokens = p.maxContextTokens; if (p.confirmTimeoutMs) this._confirmTimeoutMs = p.confirmTimeoutMs; if (p.skillDebounceMs) this._skillDebounceMs = p.skillDebounceMs; if (p.chatLockTimeoutMs) this._chatLockTimeoutMs = p.chatLockTimeoutMs; } } catch (e) {} + try { const cfg = localStorage.getItem('webx11_ai_config'); if (cfg) { const p = JSON.parse(cfg); if (p.maxTurns) this.MAX_TOOL_TURNS = p.maxTurns; if (p.maxContext) this.maxContext = p.maxContext; if (p.maxContextTokens) this.maxContextTokens = p.maxContextTokens; if (p.confirmTimeoutMs) this._confirmTimeoutMs = p.confirmTimeoutMs; if (p.skillDebounceMs) this._skillDebounceMs = p.skillDebounceMs; if (p.chatLockTimeoutMs) this._chatLockTimeoutMs = p.chatLockTimeoutMs; if (p.maxReadPageChars) this.maxReadPageChars = p.maxReadPageChars; if (p.maxToolResultChars) this.maxToolResultChars = p.maxToolResultChars; if (p.toolContextBudgetChars) this.toolContextBudgetChars = p.toolContextBudgetChars; if (p.maxFetchChars) this.maxFetchChars = p.maxFetchChars; } } catch (e) {} try { const s = localStorage.getItem('webx11_ai_patterns'); if (s) this._commandFreq = JSON.parse(s); } catch (e) {} try { const p = localStorage.getItem('webx11_user_profile'); if (p) this._userProfile = JSON.parse(p); } catch (e) {} // Restore AI conversation context from localStorage @@ -162,7 +162,7 @@ buildSystemPrompt() { parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path' }, offset: { type: 'integer', description: 'Start character offset (default 0)' }, - limit: { type: 'integer', description: 'Max characters to return (default 8000, max 32000)' } + limit: { type: 'integer', description: 'Max characters per page (default 8000). The effective page size follows the current model\'s budget and is capped again before the result enters context — the response carries returned_chars/total_chars/truncated/next_offset, page on those' } }, required: ['path'] } } }, { @@ -225,7 +225,8 @@ buildSystemPrompt() { url: { type: 'string', description: 'Full URL' }, 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)' } + 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)' }, }, required: ['url'] } @@ -755,6 +756,7 @@ buildSystemPrompt() { this.commands['ai-model'] = (args) => this.showModel(args); this.commands['ai-compress'] = () => this._manualCompress(); this.commands['ai-config'] = (args) => this.aiConfig(args); + this.commands['ai-stats'] = () => this.aiStats(); this.commands['commit-approve'] = (args) => this._executeCommit(args.join(' ')); this.commands['commit-deny'] = () => { if (!this._pendingCommit) return this.terminal.print('No pending commit.', 'warning'); @@ -1015,7 +1017,10 @@ buildSystemPrompt() { this.terminal.log('INFO', 'AI', 'Query: ' + userMessage.substring(0, 100)); if (loopResult && loopResult.toolTurns) { const toolNames = this._toolCallLog.slice(-(loopResult.toolTurns || 0)).map(function(e) { return e.name; }).join(', '); - this.terminal.log('INFO', 'AI', 'Used ' + loopResult.toolTurns + ' tool(s): ' + toolNames); + this.terminal.log('INFO', 'AI', 'Used ' + loopResult.toolTurns + ' tool(s): ' + toolNames + + ' · returned ' + Math.round(recent.reduce((a, e) => a + e.returnedBytes, 0) / 1024) + 'KB' + + ' · clipped ' + Math.round(recent.reduce((a, e) => a + e.clippedBytes, 0) / 1024) + 'KB' + + ' · ' + recent.filter(e => e.truncated).length + ' truncated'); } } // Auto-archive every 10 messages @@ -1344,10 +1349,87 @@ buildSystemPrompt() { // went 6 → 112 705 tokens across 8 model calls (maxContextTokens is 12 000). Two // limits keep a single turn inside the window: clip each observation as it arrives, // and elide the oldest ones once the running total passes the budget. + _recordToolCall(entry) { + const r = String(entry.result || ''); + let truncated = false, contentChars = null; + try { + if (r.charAt(0) === '{') { + const o = JSON.parse(r); + truncated = !!(o.truncated || o.clipped); + contentChars = (typeof o.content_chars === 'number') ? o.content_chars : null; + } + } catch (e) {} + const rec = { + turn: entry.turn, name: entry.name, + argsPreview: String(entry.args || '').slice(0, 120), + argsBytes: String(entry.args || '').length, + resultBytes: r.length, contentChars: contentChars, + returnedBytes: Number(entry.returnedBytes || r.length), + clippedBytes: Math.max(0, r.length - Number(entry.returnedBytes || r.length)), + truncated: truncated, durationMs: entry.durationMs || 0, + ok: !entry.error, errorKind: entry.errorKind || null, + }; + this._toolCallLog.push(rec); + if (this._toolCallLog.length > 50) this._toolCallLog.shift(); + this._toolStatsAdd(rec); + return rec; + } + _toolStatsAdd(rec) { + try { + const K = 'webx11_tool_stats'; + const s = JSON.parse(localStorage.getItem(K) || '{"v":1,"tools":{}}'); + const t = s.tools[rec.name] || (s.tools[rec.name] = { calls: 0, ok: 0, ms: 0, bytes: 0, truncated: 0 }); + t.calls++; t.ok += rec.ok ? 1 : 0; t.ms += rec.durationMs; + t.bytes += rec.returnedBytes; t.truncated += rec.truncated ? 1 : 0; + localStorage.setItem(K, JSON.stringify(s)); + } catch (e) {} + } + // ── Tool budgets: call arg > loop profile > model profile > ai-config > code default ── + // ── Tool budgets: call arg > loop profile > model profile > ai-config > code default ── + _modelProfile() { + try { + // `this.modelProfiles` is set by the node harness; in the browser the bundle + // supplies the top-level MODEL_PROFILES constant. Never require() a sibling here — + // the path resolves relative to whoever called the module (found the hard way). + const mp = this.modelProfiles + || (typeof MODEL_PROFILES !== 'undefined' ? MODEL_PROFILES : null); + const id = this.terminal && this.terminal.ollamaManager + ? this.terminal.ollamaManager.currentModel : ''; + return mp && mp.profileFor ? mp.profileFor(id) : null; + } catch (e) { return null; } + } + _toolLimit(key, callArg) { + const explicit = { readPageChars: this.maxReadPageChars, toolResultChars: this.maxToolResultChars, + toolContextBudgetChars: this.toolContextBudgetChars, fetchChars: this.maxFetchChars }[key]; + const prof = this._modelProfile() || {}; + const codeDefault = { readPageChars: 16000, toolResultChars: 6000, + toolContextBudgetChars: 24000, fetchChars: 4000 }[key]; + if (Number(callArg) > 0) return Number(callArg); + if (Number(explicit) > 0) return Number(explicit); + if (Number(prof[key]) > 0) return Number(prof[key]); + return codeDefault; + } _clipToolResult(result) { + const max = this._toolLimit('toolResultChars'); const text = typeof result === 'string' ? result : JSON.stringify(result); - const max = this.maxToolResultChars || 6000; if (!text || text.length <= max) return text; + // A tool envelope is JSON with a `content` field. Clipping the envelope blind deletes + // `truncated` / `next_offset`, so paging loses its continuation pointer (measured + // 2026-11-02: a 16 000-char page serialises to ~16 080 chars, cut at 6 000). + if (text.charAt(0) === '{') { + try { + const obj = JSON.parse(text); + if (obj && typeof obj.content === 'string' && obj.content.length > 0) { + const keep = Math.max(500, max - 200); // headroom for the other keys + obj.returned_chars = keep; + obj.content = obj.content.slice(0, keep) + + '\n…[clipped: ' + (obj.content.length - keep) + ' of ' + obj.content.length + + ' chars withheld — re-read with offset/limit if you need the rest]'; + obj.clipped = true; + return JSON.stringify(obj); + } + } catch (e) { /* not JSON → fall through to the string clip */ } + } return text.slice(0, max) + '\n…[clipped: ' + (text.length - max) + ' of ' + text.length + ' chars withheld — re-read with offset/limit if you need the rest]'; @@ -1736,15 +1818,9 @@ buildSystemPrompt() { toolResult = await this.runTool(fn.name, fn.arguments); this._cacheSet(fn.name, _cg.key, toolResult); } - const t1 = Math.round((Date.now() - t0) / 1000); - this._toolCallLog.push({ - turn: toolTurns, - name: fn.name, - args: fn.arguments, - result: toolResult, - duration: t1, - error: this._isToolError(toolResult) ? true : false - }); + const t1 = Date.now() - t0; + this._recordToolCall({ turn: toolTurns, name: fn.name, args: fn.arguments, + result: toolResult, durationMs: t1, error: this._isToolError(toolResult) }); _loopLog.push(this._toolCallLog[this._toolCallLog.length - 1]); // Auto-trim to 50 — prevents unbounded memory growth if (this._toolCallLog.length > 50) { @@ -2475,7 +2551,8 @@ buildSystemPrompt() { // resume. Now the caller pages with offset/limit and gets next_offset. const total = content.length; let offset = Math.max(0, Math.min(parseInt(a.offset, 10) || 0, total)); - const limit = Math.max(500, Math.min(parseInt(a.limit, 10) || 8000, 16000)); + const pageCap = Math.max(500, Math.min(this._toolLimit('readPageChars'), 200000)); + const limit = Math.max(500, Math.min(parseInt(a.limit, 10) || 8000, pageCap)); // Never split a surrogate pair (emoji/CJK-ext) across pages: // a lone surrogate makes JSON.stringify emit an invalid body. if (offset > 0) { @@ -2578,8 +2655,9 @@ buildSystemPrompt() { 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)); const endpoint = method === 'POST' ? '/api/fetch' : '/api/extract'; - const params = new URLSearchParams({ url }); + const params = new URLSearchParams({ url, max_chars: String(maxChars) }); 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' }); @@ -2590,10 +2668,15 @@ buildSystemPrompt() { // For POST/route, the response is raw text; for GET/extract it's JSON if (method === 'POST') { const text = await resp.text(); - return JSON.stringify({ method, content: text.substring(0, 4000), content_length: text.length }); + 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 data = await resp.json(); - return JSON.stringify({ method, title: data.title || '', content: (data.content || '').substring(0, 4000), word_count: data.word_count || 0, method_proxy: data.method || 'extract' }); + 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' }); } catch (e) { return JSON.stringify({ error: 'Fetch failed: ' + e.message }); } } case 'wpm_install': { @@ -3867,12 +3950,30 @@ buildSystemPrompt() { _getAiConfigKeys() { return ['max-turns', 'max-context', 'max-context-tokens', 'confirm-timeout', 'chat-lock-timeout', 'skill-debounce', 'trivial-optimization', 'temperature', 'top-p', 'max-tokens', 'thinking']; } + aiStats() { + let s = { tools: {} }; + try { s = JSON.parse(localStorage.getItem('webx11_tool_stats') || '{"tools":{}}'); } catch (e) {} + const rows = Object.keys(s.tools || {}).sort((a, b) => s.tools[b].calls - s.tools[a].calls); + if (!rows.length) return this.terminal.print('ai-stats: no tool calls recorded yet', 'info'); + this.terminal.print('=== Tool telemetry (this browser) ===', 'info'); + for (const n of rows) { + const t = s.tools[n]; + this.terminal.print(`${n}: ${t.calls} calls · ok ${Math.round(100 * t.ok / t.calls)}% · ` + + `avg ${Math.round(t.ms / t.calls)}ms · returned ${(t.bytes / 1024).toFixed(1)}KB · ` + + `truncated ${t.truncated} (${Math.round(100 * t.truncated / t.calls)}%)`, ''); + } + this.terminal.print('ai-stats reset — clear localStorage webx11_tool_stats', 'info'); + } aiConfig(args) { if (args.length === 0) { this.terminal.print('=== AI Config ===', 'info'); this.terminal.print(`max-turns: ${this.MAX_TOOL_TURNS} (default: 8)`, ''); this.terminal.print(`max-context: ${this.maxContext} (default: 10)`, ''); this.terminal.print(`max-context-tokens: ${this.maxContextTokens} (code default: 12000)`, ''); +this.terminal.print(`read-page-chars: ${this._toolLimit('readPageChars')} (model default applied; code default 16000)`, ''); +this.terminal.print(`read-result-chars: ${this._toolLimit('toolResultChars')} (model default applied; code default 6000)`, ''); +this.terminal.print(`tool-context-budget: ${this._toolLimit('toolContextBudgetChars')} (code default 24000)`, ''); +this.terminal.print(`fetch-chars: ${this._toolLimit('fetchChars')} (code default 4000)`, ''); this.terminal.print(`trivial-optimization: ${(this.terminal?.ollamaManager?.optimizationEnabled) ? 'on' : 'off (default)'}`, ''); this.terminal.print(`confirm-timeout: ${this._confirmTimeoutMs > 0 ? this._confirmTimeoutMs / 1000 + 's' : 'disabled (no auto-deny)'} (default: 120s)`, ''); this.terminal.print(`chat-lock-timeout: ${this._chatLockTimeoutMs / 1000}s (default: 10s)`, ''); @@ -3938,6 +4039,14 @@ buildSystemPrompt() { if (!validModes.includes(val)) return this.terminal.print(`thinking must be one of: ${validModes.join(', ')}`, 'warning'); const om = this.terminal?.ollamaManager; if (om && om.modelParams) { om.modelParams.thinking = val; om._saveModelParams(); } +} else if (key === 'read-page-chars' || key === 'read-result-chars' + || key === 'tool-context-budget' || key === 'fetch-chars') { + const n = parseInt(val, 10); + if (isNaN(n) || n < 500 || n > 200000) return this.terminal.print(key + ' must be 500-200000', 'warning'); + if (key === 'read-page-chars') this.maxReadPageChars = n; + if (key === 'read-result-chars') this.maxToolResultChars = n; + if (key === 'tool-context-budget') this.toolContextBudgetChars = n; + if (key === 'fetch-chars') this.maxFetchChars = n; } else { return this.terminal.print(`Unknown key: ${key}. Valid: max-turns, max-context, max-context-tokens, confirm-timeout, chat-lock-timeout, skill-debounce, trivial-optimization, temperature, top-p, max-tokens, thinking`, 'warning'); } @@ -3948,7 +4057,11 @@ buildSystemPrompt() { maxContextTokens: this.maxContextTokens, confirmTimeoutMs: this._confirmTimeoutMs, chatLockTimeoutMs: this._chatLockTimeoutMs, - skillDebounceMs: this._skillDebounceMs + skillDebounceMs: this._skillDebounceMs, +maxReadPageChars: this.maxReadPageChars, +maxToolResultChars: this.maxToolResultChars, +toolContextBudgetChars: this.toolContextBudgetChars, +maxFetchChars: this.maxFetchChars, })); this.terminal.print(`ai-config ${key} set to ${val}`, 'success'); } diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index 0cc4dd2..a0be94c 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.136'; + const LATEST_VER = 'v4.6.137'; 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/ollama-manager.js b/modules/core/ollama-manager.js index 7dcecc3..1201280 100644 --- a/modules/core/ollama-manager.js +++ b/modules/core/ollama-manager.js @@ -2,6 +2,31 @@ // Refactored to support multiple LLM providers with auto-fallback // Class name kept as OllamaManager for backward compatibility class OllamaManager { + + async _httpError(response) { + // Surface what the server actually said. "HTTP 429" tells the user nothing; the body + // carries e.g. "Demo limit reached. Login or sign up for more." (2026-10-28) + let detail = ''; + try { + const text = await response.text(); + if (text) { + try { + const j = JSON.parse(text); + detail = (j && (j.error || j.message)) || ''; + if (j && j.needed_usd != null && j.balance_usd != null) { + detail += ` (balance $${j.balance_usd}, need $${j.needed_usd})`; + } + } catch (_) { + detail = text.slice(0, 200); + } + } + } catch (_) { /* body unreadable or already consumed */ } + if (/^\s*</.test(detail)) detail = ''; // an HTML error page is not a message + const err = new Error(detail ? `${detail} (HTTP ${response.status})` : `HTTP ${response.status}`); + err.status = response.status; + return err; + } + constructor(terminal) { this.terminal = terminal; // --- Backward-compat properties (reflected from active provider) --- @@ -61,7 +86,14 @@ class OllamaManager { // just told. Send it on every Ollama request; the OpenAI-compatible branch has its own params. _ctxTokens() { const n = Number(this.numCtx || (typeof localStorage !== 'undefined' ? localStorage.getItem('webx11_ollama_numctx') : 0)); - return Number.isFinite(n) && n >= 512 ? Math.floor(n) : 16384; + if (Number.isFinite(n) && n >= 512) return Math.floor(n); + // No explicit setting: take the model class default (large/cloud 65536, mid 32768, …) + try { + const mp = this.modelProfiles + || (typeof MODEL_PROFILES !== 'undefined' ? MODEL_PROFILES : null); + if (mp && mp.profileFor && this.currentModel) return mp.profileFor(this.currentModel).ctx; + } catch (e) {} + return 16384; } _ollamaOptions(extra) { @@ -261,7 +293,7 @@ class OllamaManager { const response = await fetch(`${ep}/api/tags`, { method: 'GET', headers: { 'Content-Type': 'application/json' } }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (!response.ok) throw await this._httpError(response); const data = await response.json(); this.models = data.models || []; this.connected = true; @@ -298,7 +330,7 @@ class OllamaManager { const response = await fetch(`${provider.endpoint}/v1/models`, { method: 'GET', headers }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (!response.ok) throw await this._httpError(response); const data = await response.json(); this.models = (data.data || data.models || []).map(m => ({ name: m.id || m.name, @@ -336,7 +368,7 @@ class OllamaManager { const response = await fetch(`${ep}/api/tags`, { method: 'GET', headers: { 'Content-Type': 'application/json' } }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (!response.ok) throw await this._httpError(response); const data = await response.json(); this.models = data.models || []; this.connected = true; @@ -492,7 +524,7 @@ class OllamaManager { body: JSON.stringify({ model: useModel, prompt: prompt, stream: this.streamEnabled, options: this._ollamaOptions() }), signal }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (!response.ok) throw await this._httpError(response); if (this.streamEnabled) { const reader = response.body.getReader(); @@ -549,7 +581,7 @@ class OllamaManager { }), signal }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (!response.ok) throw await this._httpError(response); if (this.streamEnabled) { const reader = response.body.getReader(); @@ -662,7 +694,7 @@ class OllamaManager { body: JSON.stringify({ model: useModel, prompt: prompt, stream: this.streamEnabled, options: this._ollamaOptions() }), signal }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (!response.ok) throw await this._httpError(response); let fullResponse = ''; if (this.streamEnabled) { @@ -725,7 +757,7 @@ class OllamaManager { }), signal }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (!response.ok) throw await this._httpError(response); let fullResponse = ''; if (this.streamEnabled) { @@ -850,7 +882,7 @@ class OllamaManager { if (response.status === 400) { return await this.chatWithToolsFallback(messages, tools, useModel, signal); } - throw new Error(`HTTP ${response.status}`); + throw await this._httpError(response); } const data = await response.json(); this.terminal.log('INFO', 'Ollama', `chatWithTools round-trip: ${networkTime}s (model: ${useModel}, provider: ${provider.name})`); @@ -900,7 +932,7 @@ class OllamaManager { }); const networkTime = Math.round((Date.now() - t0) / 1000); if (!response.ok) { - throw new Error(`HTTP ${response.status}`); + throw await this._httpError(response); } const data = await response.json(); this.terminal.log('INFO', 'Ollama', `chatWithTools round-trip: ${networkTime}s (model: ${useModel}, provider: ${provider.name})`); @@ -1039,7 +1071,7 @@ class OllamaManager { body: JSON.stringify(body), signal }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (!response.ok) throw await this._httpError(response); const data = await response.json(); const text = data.choices?.[0]?.message?.content || ''; const latency = Date.now() - t0; @@ -1075,7 +1107,7 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (!response.ok) throw await this._httpError(response); const data = await response.json(); const text = (data.message?.content || '').trim(); const latency = Date.now() - t0; @@ -1126,7 +1158,7 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm body: JSON.stringify(body), signal }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (!response.ok) throw await this._httpError(response); const data = await response.json(); const text = data.message?.content || data.response || ''; const latency = Date.now() - t0; diff --git a/modules/test/test-tool-context-bound.js b/modules/test/test-tool-context-bound.js index 8cf2a5e..0b42eeb 100644 --- a/modules/test/test-tool-context-bound.js +++ b/modules/test/test-tool-context-bound.js @@ -85,6 +85,52 @@ function ok(name, cond, detail) { ok('D1 small tool results stay verbatim', messages[1].content === 'small result', messages[1].content); } +// E. MAINT-023b: the clip must preserve envelope keys. A read_file page is a JSON string; +// clipping it blind deletes `truncated` and `next_offset`, so the model cannot page. +{ + const ai = mk(); + const page = JSON.stringify({ + path: '/big.txt', offset: 0, returned: 16000, total_chars: 40000, + truncated: true, next_offset: 16000, + content: 'x'.repeat(16000), + }); + const clipped = ai._clipToolResult(page); + let parsed = null; + try { parsed = JSON.parse(clipped); } catch (e) {} + ok('E1 clip keeps JSON parseable', !!parsed, clipped.slice(-80)); + ok('E2 clip keeps next_offset', parsed && parsed.next_offset === 16000); + ok('E3 clip keeps truncated flag', parsed && parsed.truncated === true); + ok('E4 clip shortens content', parsed && parsed.content.length < 16000 && parsed.content.length >= 3000, + parsed && String(parsed.content.length)); + ok('E5 clipped envelope fits the limit', clipped.length <= 6000 + 400, String(clipped.length)); +} + +// F. budgets are resolvable per model class, with precedence call arg > profile > ai-config > default +{ + let PROF = null, profErr = ''; + try { PROF = require(path.join(__dirname, '..', 'core', 'model-profiles.js')); } catch (e) { profErr = e.message; } + ok('F0 model-profiles.js loads', !!PROF, profErr); + const ai = mk(); + ai.modelProfiles = PROF; + ai.terminal.ollamaManager = { currentModel: 'gemma4:31b-cloud' }; + ok('F1 cloud profile read page', ai._toolLimit('readPageChars') === 32000, String(ai._toolLimit('readPageChars'))); + ai.terminal.ollamaManager.currentModel = 'qwen3:0.6b'; + ok('F2 tiny profile read page', ai._toolLimit('readPageChars') === 4000, String(ai._toolLimit('readPageChars'))); + ai.terminal.ollamaManager.currentModel = 'llama3.1:8b'; + ok('F3 small profile result clip', ai._toolLimit('toolResultChars') === 4000, String(ai._toolLimit('toolResultChars'))); + ai.maxToolResultChars = 1234; + ok('F4 ai-config overrides the model default', ai._toolLimit('toolResultChars') === 1234, String(ai._toolLimit('toolResultChars'))); + // an id with no size token is unclassifiable -> mid; "unknown:1b" is NOT unknown, it is a 1B model + ok('F5 unclassifiable model falls back to mid', (function () { + ai.terminal.ollamaManager.currentModel = 'mystery-model'; + return ai._toolLimit('readPageChars') === 16000; + })()); + ok('F6 a 1b id is classified tiny, not mid', (function () { + ai.terminal.ollamaManager.currentModel = 'unknown:1b'; + return ai._toolLimit('readPageChars') === 4000; + })()); +} + if (fails === 0) { console.log('test-tool-context-bound: ' + passes + ' passed, 0 failed'); } else { diff --git a/scripts/rebuild-bundles.py b/scripts/rebuild-bundles.py index a231e32..7f82817 100644 --- a/scripts/rebuild-bundles.py +++ b/scripts/rebuild-bundles.py @@ -8,6 +8,8 @@ import os ROOT = '/var/www/webx11.duckdns.org/spaces/newos4' CORE_FILES = [ + # First: a top-level `const` later in the bundle is in TDZ for `typeof MODEL_PROFILES`. + 'model-profiles.js', 'virtual-fs.js', 'module-manager.js', 'ollama-manager.js', |