aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-11-02 20:46:18 +0100
committerAgando <roduccomm@gmail.com>2026-11-02 20:46:18 +0100
commit8d5c4b563da5225c4dbd2a44c7635c49a16d5ab1 (patch)
treede05cf1dcda3b45f131e4b1fca85421b49facdd6
parent89787327ec5aba15d11ca7bff2b4fb7f0df24a79 (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.html3
-rw-r--r--main.html6
-rw-r--r--modules-index.json4
-rw-r--r--modules/core/core-bundle.js205
-rw-r--r--modules/core/model-profiles.js43
-rw-r--r--modules/core/module-aiassist.js151
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/core/ollama-manager.js58
-rw-r--r--modules/test/test-tool-context-bound.js46
-rw-r--r--scripts/rebuild-bundles.py2
10 files changed, 462 insertions, 58 deletions
diff --git a/help.html b/help.html
index b2caf27..faf7af6 100644
--- a/help.html
+++ b/help.html
@@ -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>
diff --git a/main.html b/main.html
index a785d7c..5681c10 100644
--- a/main.html
+++ b/main.html
@@ -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',