aboutsummaryrefslogtreecommitdiff
path: root/modules/core
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-21 22:47:43 +0200
committerAgando <roduccomm@gmail.com>2026-10-21 22:47:43 +0200
commit94618145bbcbf467e5e7f28fb81aac77dd1c263c (patch)
tree412226b46b28ec7b759997a1c42394dde7b4a84a /modules/core
parent586a3126c4cee9d9c6f4713568c693cb8002a77b (diff)
feat(newos4): AI paged file reads, durable tool traces, 12k context budget, read/tool/verify prompt rules (v4.6.46)
- read_file: offset/limit + JSON {total_chars,truncated,next_offset} (was a hard 8000-char cut with no resume) - tool results now leave a per-turn trace that is persisted into session context and survives compression - maxContextTokens 2000 -> 12000; compression keeps 8 exchanges and 400 chars/msg - system prompt: page big files, prefer grep -n / sed -n / head, re-read + verify after every edit - verified in the live browser: 30k file pages 0->8000->16000, prompt section present, v4.6.46 served
Diffstat (limited to 'modules/core')
-rw-r--r--modules/core/core-bundle.js65
-rw-r--r--modules/core/module-aiassist.js106
-rw-r--r--modules/core/module-loader.js2
3 files changed, 140 insertions, 33 deletions
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index 3f7e352..4a2416c 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -8112,7 +8112,7 @@ class ServerMonitorModule {
// Only deterministic reads are cached — never status/log/polling tools.
this._cacheableTools = new Set(['read_file', 'list_files', 'list_modules', 'view_module',
'nvfs_resolve', 'get_help', 'list_skills', 'diff_module', 'get_module_status']);
- this.maxContextTokens = 2000;
+ this.maxContextTokens = 12000;
this.MAX_TOOL_TURNS = 8;
this._pendingConfirmation = null;
this._confirmResolve = null;
@@ -8217,6 +8217,12 @@ buildSystemPrompt() {
base += '[/Auto-Fix Suggestions]';
}
} catch (e) {}
+ base += '\n\n[File reads, tools, verification]\n' +
+ '- read_file returns at most 8000 chars. If the reply says truncated=true, call read_file again with offset=<next_offset> before concluding anything about the file.\n' +
+ '- Prefer targeted reads over whole files: run_command with grep -n "<pattern>" <file>, sed -n \'10,40p\' <file>, head -40 <file>, tail -40 <file>, wc -l <file>.\n' +
+ '- sed without -i is read-only and allowed; sed -i, rm, mv and writes need user confirmation.\n' +
+ '- After ANY write or edit: re-read the changed lines, then run a check (test, node scripts/lint-no-undef.py, or a curl) and say in one line what you verified.\n' +
+ '- If a tool fails twice, change approach (different tool, smaller scope) instead of retrying the same call.';
return base;
}
getToolDefinitions() {
@@ -8249,8 +8255,12 @@ buildSystemPrompt() {
type: 'function',
function: {
name: 'read_file',
- description: 'Read VFS file',
- parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path' } }, required: ['path'] }
+ description: 'Read a VFS file. Large files come back in pages: if the reply has truncated=true, call again with offset=<next_offset> instead of guessing or giving up. Returns JSON {path, offset, returned, total_chars, truncated, next_offset, content}.',
+ 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)' }
+ }, required: ['path'] }
}
}, {
type: 'function',
@@ -9001,6 +9011,12 @@ buildSystemPrompt() {
if (loopResult.finalAnswer) {
sess.context.push({ role: 'assistant', content: loopResult.finalAnswer });
}
+ // Durable trace of what was read/run this turn (2026-10-21) — without this,
+ // every later turn started blind and the model re-read the same files.
+ if (this._turnObservations && this._turnObservations.length) {
+ sess.context.push({ role: 'assistant', content: '⚙️ tool trace: ' + this._turnObservations.join(' | ') });
+ }
+ this._turnObservations = [];
if (sess.context.length > this.maxContext * 2) {
sess.context.splice(0, sess.context.length - this.maxContext * 2);
}
@@ -9363,6 +9379,19 @@ buildSystemPrompt() {
}
return result.substring(0, maxLen) + '...(truncated from ' + result.length + ' chars)';
}
+ // 2026-10-21: tool results lived only inside the current turn, so the next turn
+ // had no idea what the model had already read or run. Keep a compact per-turn
+ // trace and persist it into the session context (see the turn-end push).
+ _noteObservation(name, args, result) {
+ try {
+ if (!this._turnObservations) this._turnObservations = [];
+ if (this._turnObservations.length >= 12) return;
+ const argStr = (typeof args === 'object' && args !== null) ? JSON.stringify(args) : String(args || '');
+ const note = (name + '(' + argStr + ') → ' + String(result === undefined || result === null ? '' : result))
+ .replace(/\s+/g, ' ').trim();
+ this._turnObservations.push(note.slice(0, 240));
+ } catch (_e) {}
+ }
_escapeHtml(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
@@ -9710,6 +9739,7 @@ buildSystemPrompt() {
}
} catch (_e) {}
messages.push({ role: 'tool', tool_call_id: tc.id, content: toolResult });
+ this._noteObservation(fn.name, fn.arguments, toolResult);
}
// Only block on destructive tools
if (destructiveTools.length > 0) {
@@ -9784,6 +9814,7 @@ buildSystemPrompt() {
}
} catch (_e) {}
messages.push({ role: 'tool', tool_call_id: tc.id, content: toolResult });
+ this._noteObservation(fn.name, fn.arguments, toolResult);
}
} else {
messages.push({
@@ -10400,8 +10431,20 @@ buildSystemPrompt() {
const path = rp.replace(/^\//, '');
const content = this.terminal.fs.readFile(path);
this.terminal.fs.currentPath = savedR;
- if (content === null) return JSON.stringify({ error: `File not found: ${path}` });
- return content.length > 8000 ? content.substring(0, 8000) + '\n...(truncated)' : content;
+ if (content === null || content === undefined) return JSON.stringify({ error: `File not found: ${path}` });
+ // Paged reads (2026-10-21): the old 8000-char hard cap made any
+ // bigger file unreadable — the model could neither finish it nor
+ // resume. Now the caller pages with offset/limit and gets next_offset.
+ const total = content.length;
+ const offset = Math.max(0, Math.min(parseInt(a.offset, 10) || 0, total));
+ const limit = Math.max(1000, Math.min(parseInt(a.limit, 10) || 8000, 32000));
+ const slice = content.slice(offset, offset + limit);
+ const nextOffset = offset + slice.length < total ? offset + slice.length : null;
+ return JSON.stringify({
+ path: path, offset: offset, returned: slice.length,
+ total_chars: total, truncated: nextOffset !== null,
+ next_offset: nextOffset, content: slice
+ });
}
case 'write_file': {
const wp = a.path || '';
@@ -11956,18 +11999,24 @@ buildSystemPrompt() {
if (this.context.length <= 6) return;
const totalTokens = this._estimateContextTokens();
if (totalTokens < this.maxContextTokens) return;
- const keepCount = 3;
+ const keepCount = 8;
const oldMsgs = this.context.slice(0, -keepCount * 2);
if (oldMsgs.length < 2) return;
const recentMsgs = this.context.slice(-keepCount * 2);
+ // Tool traces are the only durable record of what was read/run — keep them
+ // verbatim instead of folding them into the 400-char digest (2026-10-21).
const summaryText = oldMsgs.map(m =>
+ /^⚙️ tool trace:/.test(m.content || '') ? (m.content || '').slice(0, 600) :
(m.role === 'user' ? '👤' : m.role === 'assistant' ? '🤖' : '⚙️') + ': ' +
- (m.content || '').substring(0, 150)
+ (m.content || '').substring(0, 400)
).join(' | ');
let compressed = `[Earlier context (${oldMsgs.length} messages): ${summaryText}]`;
+ // Tool traces must survive compression, so the AI one-sentence summary is only
+ // used when there are none to lose (2026-10-21).
+ const _traces = oldMsgs.filter(m => /^⚙️ tool trace:/.test(m.content || ''));
// Try AI summarization, fallback to truncation
try {
- if (this.terminal.ollamaManager && this.terminal.ollamaManager.connected) {
+ if (!_traces.length && this.terminal.ollamaManager && this.terminal.ollamaManager.connected) {
const sumPrompt = 'Summarize these conversation exchanges into a single compact sentence. Focus on what the user asked and what actions were taken. Keep it under 200 characters.\n\n' + JSON.stringify(oldMsgs.map(m => ({ role: m.role, content: (m.content || '').substring(0, 200) })));
const sumRes = await this.terminal.ollamaManager.chat(sumPrompt);
if (sumRes && sumRes.length < 300) {
diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js
index 95a29a7..90edf73 100644
--- a/modules/core/module-aiassist.js
+++ b/modules/core/module-aiassist.js
@@ -11,11 +11,11 @@
this._toolEpoch = 0;
this._mutatingTools = new Set(['write_file', 'edit_module', 'run_command', 'delete_file',
'make_directory', 'commit_module', 'ship_module', 'patch_skill', 'delegate_task',
- 'reload_module', 'create_skill']);
+ 'reload_module', 'create_skill', 'create_mindmap']);
// Only deterministic reads are cached — never status/log/polling tools.
this._cacheableTools = new Set(['read_file', 'list_files', 'list_modules', 'view_module',
'nvfs_resolve', 'get_help', 'list_skills', 'diff_module', 'get_module_status']);
- this.maxContextTokens = 2000;
+ this.maxContextTokens = 12000;
this.MAX_TOOL_TURNS = 8;
this._pendingConfirmation = null;
this._confirmResolve = null;
@@ -120,6 +120,12 @@ buildSystemPrompt() {
base += '[/Auto-Fix Suggestions]';
}
} catch (e) {}
+ base += '\n\n[File reads, tools, verification]\n' +
+ '- read_file returns at most 8000 chars. If the reply says truncated=true, call read_file again with offset=<next_offset> before concluding anything about the file.\n' +
+ '- Prefer targeted reads over whole files: run_command with grep -n "<pattern>" <file>, sed -n \'10,40p\' <file>, head -40 <file>, tail -40 <file>, wc -l <file>.\n' +
+ '- sed without -i is read-only and allowed; sed -i, rm, mv and writes need user confirmation.\n' +
+ '- After ANY write or edit: re-read the changed lines, then run a check (test, node scripts/lint-no-undef.py, or a curl) and say in one line what you verified.\n' +
+ '- If a tool fails twice, change approach (different tool, smaller scope) instead of retrying the same call.';
return base;
}
getToolDefinitions() {
@@ -152,8 +158,12 @@ buildSystemPrompt() {
type: 'function',
function: {
name: 'read_file',
- description: 'Read VFS file',
- parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path' } }, required: ['path'] }
+ description: 'Read a VFS file. Large files come back in pages: if the reply has truncated=true, call again with offset=<next_offset> instead of guessing or giving up. Returns JSON {path, offset, returned, total_chars, truncated, next_offset, content}.',
+ 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)' }
+ }, required: ['path'] }
}
}, {
type: 'function',
@@ -822,10 +832,14 @@ buildSystemPrompt() {
}
}
} catch (e) { console.warn('[AI] Auto-routing error:', e.message); }
- // Re-read context from localStorage (auto-restore may have written it after init)
- this._loadContext();
- // Compress context if it exceeds the token threshold
- this._compressContext();
+ // Agent runs must not touch the user's main conversation: _loadContext()/ _compressContext()
+ // reassign this.context, which would detach the session captured below.
+ const _isAgentRun = !!opts.agentTag;
+ if (!_isAgentRun) {
+ this._loadContext();
+ await this._compressContext();
+ this._toolCache.clear(); // out-of-band VFS edits may have happened since the last chat
+ }
// Per-agent session: 'main' aliases this.context, agents get their own history.
const sess = this._sessionCtx(opts.agentTag);
let loopResult;
@@ -900,6 +914,12 @@ buildSystemPrompt() {
if (loopResult.finalAnswer) {
sess.context.push({ role: 'assistant', content: loopResult.finalAnswer });
}
+ // Durable trace of what was read/run this turn (2026-10-21) — without this,
+ // every later turn started blind and the model re-read the same files.
+ if (this._turnObservations && this._turnObservations.length) {
+ sess.context.push({ role: 'assistant', content: '⚙️ tool trace: ' + this._turnObservations.join(' | ') });
+ }
+ this._turnObservations = [];
if (sess.context.length > this.maxContext * 2) {
sess.context.splice(0, sess.context.length - this.maxContext * 2);
}
@@ -949,8 +969,8 @@ buildSystemPrompt() {
} finally {
this._chatLock = false;
this.terminal.setAiBusy(false);
- // Persist conversation context after each exchange
- this._saveContext();
+ // Persist only the user's conversation — agent sessions are ephemeral.
+ if (!_isAgentRun) this._saveContext();
// Log the AI interaction
this.terminal.log('INFO', 'AI', 'Query: ' + userMessage.substring(0, 100));
if (loopResult && loopResult.toolTurns) {
@@ -1262,6 +1282,19 @@ buildSystemPrompt() {
}
return result.substring(0, maxLen) + '...(truncated from ' + result.length + ' chars)';
}
+ // 2026-10-21: tool results lived only inside the current turn, so the next turn
+ // had no idea what the model had already read or run. Keep a compact per-turn
+ // trace and persist it into the session context (see the turn-end push).
+ _noteObservation(name, args, result) {
+ try {
+ if (!this._turnObservations) this._turnObservations = [];
+ if (this._turnObservations.length >= 12) return;
+ const argStr = (typeof args === 'object' && args !== null) ? JSON.stringify(args) : String(args || '');
+ const note = (name + '(' + argStr + ') → ' + String(result === undefined || result === null ? '' : result))
+ .replace(/\s+/g, ' ').trim();
+ this._turnObservations.push(note.slice(0, 240));
+ } catch (_e) {}
+ }
_escapeHtml(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
@@ -1368,6 +1401,7 @@ buildSystemPrompt() {
var baseTurns = maxTurns;
let _lastTurnSig = null, _progressStreak = 0, _stallStreak = 0;
const _logStart = (this._toolCallLog || []).length;
+ const _loopLog = []; // per-run tool log (the global one self-trims to 50 and breaks slicing)
// D3: extend the budget only while turns keep producing NEW output.
const _noteProgress = (results) => {
try {
@@ -1434,7 +1468,7 @@ buildSystemPrompt() {
for (let mi = messages.length - 1; mi >= 0; mi--) {
if (messages[mi] && messages[mi]._ledger) messages.splice(mi, 1);
}
- const _led = (this._toolCallLog || []).slice(_logStart).slice(-8).map(e =>
+ const _led = _loopLog.slice(-8).map(e =>
'- ' + e.name + '(' + String(typeof e.args === 'string' ? e.args : JSON.stringify(e.args || {})).slice(0, 60) + ') → ' +
(e.error ? 'ERROR' : this._summarizeObservation(e.result, 80))
).join('\n');
@@ -1477,8 +1511,9 @@ buildSystemPrompt() {
for (const tc of res.tool_calls) {
const sig = tc.function.name + ':' + (tc.function.arguments || '');
let entry = _seenCalls.get(sig);
- if (!entry || (now - entry.ts) > _loopWindow) {
- entry = { count: 0, ts: now, resultHash: null, blocked: false, lastResult: null };
+ if (!entry || (now - entry.ts) > _loopWindow || entry.epoch !== this._toolEpoch) {
+ // A mutation since the last attempt invalidates the block: re-run instead of replaying.
+ entry = { count: 0, ts: now, resultHash: null, blocked: false, lastResult: null, epoch: this._toolEpoch };
}
entry.count++;
entry.ts = now;
@@ -1508,8 +1543,9 @@ buildSystemPrompt() {
: JSON.stringify({ blocked: true, reason: 'identical call repeated with unchanged result', tool: tc.function.name }) });
}
this.terminal.print(`🛑 Loop guard: ${blocked.length} repeated call(s) skipped — agent continues with cached data`, 'info');
- if (blocked.some(b => b.entry.count >= _hardStopAt)) {
- this.terminal.print(`⛔ ${_agentTag}: no progress after ${_hardStopAt} identical calls — ending loop with partial result`, 'error');
+ const _hs = Math.min(_hardStopAt, maxTurns);
+ if (blocked.some(b => b.entry.count >= _hs)) {
+ this.terminal.print(`⛔ ${_agentTag}: no progress after ${_hs} identical calls — ending loop with partial result`, 'error');
return { finalAnswer: this._loopSummary(toolTurns), toolTurns };
}
continue;
@@ -1557,6 +1593,7 @@ buildSystemPrompt() {
duration: t1,
error: this._isToolError(toolResult) ? true : false
});
+ _loopLog.push(this._toolCallLog[this._toolCallLog.length - 1]);
// Auto-trim to 50 — prevents unbounded memory growth
if (this._toolCallLog.length > 50) {
this._toolCallLog = this._toolCallLog.slice(-50);
@@ -1565,9 +1602,9 @@ buildSystemPrompt() {
const sm = this.terminal.moduleManager.modules['ServerMonitorModule'];
if (sm && sm.logToolCall) sm.logToolCall(fn.name, (t1 * 1000) || 1, this._isToolError(toolResult));
if (this._debugPanelEnabled) this._updateDebugPanel();
- safeResults.push({ fn, toolResult, t1 });
+ safeResults.push({ tc, fn, toolResult, t1 });
}
- for (const { fn, toolResult, t1 } of safeResults) {
+ for (const { tc, fn, toolResult, t1 } of safeResults) {
this.terminal.print(`🔧 Tool: ${fn.name}(${typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments)})`, 'info');
// Memory graph: record tool call + file access
try {
@@ -1604,7 +1641,8 @@ buildSystemPrompt() {
_eR.lastResult = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
}
} catch (_e) {}
- messages.push({ role: 'tool', content: toolResult });
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: toolResult });
+ this._noteObservation(fn.name, fn.arguments, toolResult);
}
// Only block on destructive tools
if (destructiveTools.length > 0) {
@@ -1638,6 +1676,7 @@ buildSystemPrompt() {
duration: t1,
error: this._isToolError(toolResult) ? true : false
});
+ _loopLog.push(this._toolCallLog[this._toolCallLog.length - 1]);
// Auto-trim to 50 — prevents unbounded memory growth
if (this._toolCallLog.length > 50) {
this._toolCallLog = this._toolCallLog.slice(-50);
@@ -1677,7 +1716,8 @@ buildSystemPrompt() {
_eD.lastResult = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
}
} catch (_e) {}
- messages.push({ role: 'tool', content: toolResult });
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: toolResult });
+ this._noteObservation(fn.name, fn.arguments, toolResult);
}
} else {
messages.push({
@@ -2294,8 +2334,20 @@ buildSystemPrompt() {
const path = rp.replace(/^\//, '');
const content = this.terminal.fs.readFile(path);
this.terminal.fs.currentPath = savedR;
- if (content === null) return JSON.stringify({ error: `File not found: ${path}` });
- return content.length > 8000 ? content.substring(0, 8000) + '\n...(truncated)' : content;
+ if (content === null || content === undefined) return JSON.stringify({ error: `File not found: ${path}` });
+ // Paged reads (2026-10-21): the old 8000-char hard cap made any
+ // bigger file unreadable — the model could neither finish it nor
+ // resume. Now the caller pages with offset/limit and gets next_offset.
+ const total = content.length;
+ const offset = Math.max(0, Math.min(parseInt(a.offset, 10) || 0, total));
+ const limit = Math.max(1000, Math.min(parseInt(a.limit, 10) || 8000, 32000));
+ const slice = content.slice(offset, offset + limit);
+ const nextOffset = offset + slice.length < total ? offset + slice.length : null;
+ return JSON.stringify({
+ path: path, offset: offset, returned: slice.length,
+ total_chars: total, truncated: nextOffset !== null,
+ next_offset: nextOffset, content: slice
+ });
}
case 'write_file': {
const wp = a.path || '';
@@ -3850,18 +3902,24 @@ buildSystemPrompt() {
if (this.context.length <= 6) return;
const totalTokens = this._estimateContextTokens();
if (totalTokens < this.maxContextTokens) return;
- const keepCount = 3;
+ const keepCount = 8;
const oldMsgs = this.context.slice(0, -keepCount * 2);
if (oldMsgs.length < 2) return;
const recentMsgs = this.context.slice(-keepCount * 2);
+ // Tool traces are the only durable record of what was read/run — keep them
+ // verbatim instead of folding them into the 400-char digest (2026-10-21).
const summaryText = oldMsgs.map(m =>
+ /^⚙️ tool trace:/.test(m.content || '') ? (m.content || '').slice(0, 600) :
(m.role === 'user' ? '👤' : m.role === 'assistant' ? '🤖' : '⚙️') + ': ' +
- (m.content || '').substring(0, 150)
+ (m.content || '').substring(0, 400)
).join(' | ');
let compressed = `[Earlier context (${oldMsgs.length} messages): ${summaryText}]`;
+ // Tool traces must survive compression, so the AI one-sentence summary is only
+ // used when there are none to lose (2026-10-21).
+ const _traces = oldMsgs.filter(m => /^⚙️ tool trace:/.test(m.content || ''));
// Try AI summarization, fallback to truncation
try {
- if (this.terminal.ollamaManager && this.terminal.ollamaManager.connected) {
+ if (!_traces.length && this.terminal.ollamaManager && this.terminal.ollamaManager.connected) {
const sumPrompt = 'Summarize these conversation exchanges into a single compact sentence. Focus on what the user asked and what actions were taken. Keep it under 200 characters.\n\n' + JSON.stringify(oldMsgs.map(m => ({ role: m.role, content: (m.content || '').substring(0, 200) })));
const sumRes = await this.terminal.ollamaManager.chat(sumPrompt);
if (sumRes && sumRes.length < 300) {
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index 3aedc0d..6c3b48e 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.45';
+ const LATEST_VER = 'v4.6.46';
const CACHE = '?v=' + LATEST_VER;
const stored = localStorage.getItem('webx11_cache_ver');
if (stored !== LATEST_VER) {