diff options
| author | Hermes SysOp <sysop@webx11.duckdns.org> | 2026-11-09 23:58:35 +0100 |
|---|---|---|
| committer | Hermes SysOp <sysop@webx11.duckdns.org> | 2026-11-09 23:58:35 +0100 |
| commit | 9f6744dd5cb99b904f9a23fce6f2d98e8332bbf8 (patch) | |
| tree | d3f3a525e8594db010bbf0e2aa6e4c2590445339 /modules/core/module-aiassist.js | |
| parent | 8e3a6155f6a5a1266ff1fa801c8864484bcdbda9 (diff) | |
fix(newos4): six crash classes from the feedback queue (v4.6.158)
Each item is symptom -> cause -> fix, re-verified against the served bytes.
1. debug-ai panel threw "Cannot read properties of undefined (reading 'substring')"
(RPT-20261108-231622). _updateDebugPanel and two sibling previews called
JSON.stringify(entry.args).substring(); JSON.stringify(undefined) returns
undefined, and _toolCallLog entries carry args: undefined for any tool call
with no arguments. All three previews now go through _formatToolArgs(), which
renders "null" and never throws.
2. test_module threw "Cannot convert undefined or null to object"
(RPT-20261108-232127). Object.keys(instance.commands) assumed every module
initialises a command map. The throw landed after loadModuleFromCode had
already succeeded, so the rolledBack:true branches were skipped and an
unverified module stayed loaded. Both sites now require instance.commands.
3. list_files threw "originalPath.replace is not a function" (RPT-20261109-172742,
3 occurrences in errors.json). A model can send a non-string path despite the
schema. The path family (list_files/read_file/write_file/make_directory/
delete_file) now coerces through _pathArg() and answers the tool's own honest
error instead of a TypeError.
4. wpm search failed for EVERY keyword (RPT-20261109-123907): packages.json entry
`kanban` used `description` while the other 31 use `desc`, and search() called
pkg.desc.toLowerCase() mid-loop. Catalog reads now go through _pkg() /
_packageMap() (desc || description, values coerced to strings), and the kanban
entry carries name/version/desc/commands. Re-running the original loop for
pdf/git/zzz is clean.
5. wx/webxai/webx turned a chat-lock timeout into an unhandled rejection
(RPT-20261107-150726): forwardToAI() called chat(args) in a synchronous
function without returning or catching the promise, so the 10 s busy throw
never reached dispatchCommand's catch. It now returns the promise with a
.catch that prints "AI error: ..." through terminal.print — console.error is
hooked by the debug supervisor and would re-file the same report.
6. CryptoGzip._base64ToBytes threw a raw DOMException on non-base64 input
(RPT-20261102-203616). It now validates first and throws
Error("not valid base64 input (<reason>)"). The duplicate shadowing
definitions at the bottom of the class were deleted — the later copy is the
one that carried the unguarded atob().
Also regenerated modules/core/core-bundle.js (generated, never hand-edited) and
bumped the build v4.6.157 -> v4.6.158 (main.html x3 + module-loader.js).
Verified: 35/35 node tests, including 6 new regression tests each proven red
against the pre-fix source; an independent orchestrator harness 36/36; and
newos4-maintain.py --fix all PASS after regenerating the stale changelog page.
Served bytes are byte-identical for the bundle, main.html and both ext modules,
and a live headless-Chromium run against v4.6.158 shows WpmModule.search('pdf')
returning 1 result, the debug panel rendering a no-arg entry as "null",
_base64ToBytes('grüße') raising a plain Error that names base64, and 0 JS errors
on load.
m.pull (RPT-20261102-211438) is closed as a usage error: no module in the tree
defines pull(); the real interfaces are the webdav-pull and model-pull commands.
Diffstat (limited to 'modules/core/module-aiassist.js')
| -rw-r--r-- | modules/core/module-aiassist.js | 42 |
1 files changed, 32 insertions, 10 deletions
diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js index a3ffb6f..fb4329f 100644 --- a/modules/core/module-aiassist.js +++ b/modules/core/module-aiassist.js @@ -1551,6 +1551,28 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re _escapeHtml(str) { return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); } + // Tool-call args are whatever the model sent: often a JSON string, but + // undefined when the call carried no arguments and an object when the + // caller passed one directly. JSON.stringify(undefined) is undefined, + // so the old inline `JSON.stringify(x).substring(...)` threw a + // TypeError inside the debug panel. Never throw here. + _formatToolArgs(args, max) { + let s; + if (typeof args === 'string') { + s = args; + } else { + try { s = JSON.stringify(args); } catch (_e) { s = String(args); } + if (s === undefined || s === null) s = 'null'; + } + const cap = (typeof max === 'number' && max > 0) ? max : 80; + return s.substring(0, cap); + } + // Path arguments are schema-typed as strings, but a model can send a + // number/array/object anyway. Coerce instead of letting .replace() / + // .startsWith() throw "originalPath.replace is not a function". + _pathArg(v) { + return typeof v === 'string' ? v : (v == null ? '' : String(v)); + } _updateDebugPanel() { if (!this._debugPanelEnabled) return; const panel = document.getElementById('debug-ai-panel'); @@ -1560,7 +1582,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re let toolHtml = ''; for (const entry of recent) { const statusClass = entry.error ? 'debug-entry-error' : 'debug-entry-ok'; - const argsStr = typeof entry.args === 'string' ? entry.args.substring(0, 60) : JSON.stringify(entry.args).substring(0, 60); + const argsStr = this._formatToolArgs(entry.args, 60); toolHtml += '<div class="debug-entry ' + statusClass + '">' + '<span class="debug-turn">#' + entry.turn + '</span> ' + '<span class="debug-name">' + this._escapeHtml(entry.name) + '</span> ' + @@ -1599,7 +1621,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re panel.innerHTML = '<div class="debug-header" style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;border-bottom:1px solid #333;font-weight:bold;color:var(--success-color);font-size:0.9em;flex-wrap:wrap;gap:4px;">' + '<span>🔧 AI Debug <span class="debug-count" style="color:var(--text-muted);font-weight:normal;font-size:0.85em;">' + this._toolCallLog.length + ' calls</span></span>' + '<span style="display:flex;gap:4px;">' + - '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var t=m._toolCallLog.map(function(e){return\'#\'+e.turn+\' \'+e.name+\' (\'+e.duration+\'s)\'+(e.error?\' ⚠️\':\'\')+\'\\n\'+JSON.stringify(e.args).substring(0,80)}).join(\'\\n\\n\');navigator.clipboard.writeText(t).then(function(){m.terminal&&m.terminal.print(\'📋 Debug log copied to clipboard\',\'success\')}).catch(function(){prompt(\'Copy debug log:\',t)})}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">📋</button>' + + '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var t=m._toolCallLog.map(function(e){return\'#\'+e.turn+\' \'+e.name+\' (\'+e.duration+\'s)\'+(e.error?\' ⚠️\':\'\')+\'\\n\'+m._formatToolArgs(e.args,80)}).join(\'\\n\\n\');navigator.clipboard.writeText(t).then(function(){m.terminal&&m.terminal.print(\'📋 Debug log copied to clipboard\',\'success\')}).catch(function(){prompt(\'Copy debug log:\',t)})}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">📋</button>' + '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var json=JSON.stringify(m._toolCallLog,null,2);var b=new Blob([json],{type:\'application/json\'});var u=URL.createObjectURL(b);var a=document.createElement(\'a\');a.href=u;a.download=\'debug-log-\'+new Date().toISOString().slice(0,19)+\'.json\';document.body.appendChild(a);a.click();setTimeout(function(){document.body.removeChild(a);URL.revokeObjectURL(u)},2000);m.terminal&&m.terminal.print(\'📥 Debug log exported\',\'success\')}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">📥</button>' + '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var existing=[];var raw=m.terminal.fs.readFile(\'/debug.log\');if(raw){try{existing=JSON.parse(raw);}catch(e){}}var log=m._toolCallLog;var seen={};existing.forEach(function(e){seen[e.turn]=true;});log.forEach(function(e){if(!seen[e.turn]){existing.push(e);seen[e.turn]=true;}});var json=JSON.stringify(existing,null,2);m.terminal.fs.writeFile(\'/debug.log\',json);m.terminal.print(\'💾 Debug log appended — \'+existing.length+\' entries total\',\'success\')}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">💾</button>' + '<span style="cursor:pointer;color:var(--text-muted);font-size:1.1em;touch-action:manipulation;" onclick="var p=document.getElementById(\'debug-ai-panel\');if(p)p.style.display=\'none\';var a=window.webx11&&webx11.moduleManager&&webx11.moduleManager.modules&&webx11.moduleManager.modules.WebX11AIAssistantModule;if(a)a._debugPanelEnabled=false;">✕</span>' + @@ -2573,7 +2595,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {}); switch (name) { case 'list_files': { - const originalPath = a.path || ''; + const originalPath = this._pathArg(a.path); const p = originalPath.replace(/^\//, '').replace(/\/+$/, ''); const fsapi = this.terminal.fs; const saved = [...fsapi.currentPath]; @@ -2590,7 +2612,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re return JSON.stringify(entries.map(f => ({ name: f.name, type: f.type, size: f.size }))); } case 'read_file': { - const rp = a.path || ''; + const rp = this._pathArg(a.path); const savedR = [...this.terminal.fs.currentPath]; if (rp.startsWith('/')) this.terminal.fs.currentPath = []; const path = rp.replace(/^\//, ''); @@ -2629,7 +2651,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re }); } case 'write_file': { - const wp = a.path || ''; + const wp = this._pathArg(a.path); const savedW = [...this.terminal.fs.currentPath]; if (wp.startsWith('/')) this.terminal.fs.currentPath = []; const path = wp.replace(/^\//, ''); @@ -2648,7 +2670,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re return `File written: ${wp} (${content.length} chars)`; } case 'make_directory': { - const mp = a.path || ''; + const mp = this._pathArg(a.path); const savedM = [...this.terminal.fs.currentPath]; if (mp.startsWith('/')) this.terminal.fs.currentPath = []; const path = mp.replace(/^\//, ''); @@ -2658,7 +2680,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re return JSON.stringify({ error: `Cannot create: ${path}` }); } case 'delete_file': { - const dp = a.path || ''; + const dp = this._pathArg(a.path); const savedD = [...this.terminal.fs.currentPath]; if (dp.startsWith('/')) this.terminal.fs.currentPath = []; const path = dp.replace(/^\//, ''); @@ -3578,7 +3600,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re if (!code) return JSON.stringify({ error: `No local edit for ${modName}` }); // Save current module state for rollback const oldInstance = this.terminal.moduleManager.modules[modName]; - const oldCommands = oldInstance ? Object.keys(oldInstance.commands) : []; + const oldCommands = (oldInstance && oldInstance.commands) ? Object.keys(oldInstance.commands) : []; // Load new version const ok = await this.terminal.moduleManager.loadModuleFromCode(code, `blob://_mods/${modName}.js`); if (!ok) { @@ -3593,7 +3615,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re } // Verify commands registered const newInstance = this.terminal.moduleManager.modules[modName]; - const newCommands = newInstance ? Object.keys(newInstance.commands) : []; + const newCommands = (newInstance && newInstance.commands) ? Object.keys(newInstance.commands) : []; const lostCommands = oldCommands.filter(c => !newCommands.includes(c)); if (lostCommands.length > 0) { if (oldInstance) this.terminal.moduleManager.modules[modName] = oldInstance; @@ -4302,7 +4324,7 @@ maxFetchChars: this.maxFetchChars, for (var i = 0; i < this._toolCallLog.length; i++) { var e = this._toolCallLog[i]; var err = e.error ? ' ⚠️' : ''; - var argsStr = typeof e.args === 'string' ? e.args.substring(0, 80) : JSON.stringify(e.args).substring(0, 80); + var argsStr = this._formatToolArgs(e.args, 80); out += ' #' + e.turn + ' ' + e.name + '(' + argsStr + ') ' + e.duration + 's' + err + '\n'; } out += '── End ──'; |