aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-22 00:46:05 +0200
committerAgando <roduccomm@gmail.com>2026-10-22 00:46:05 +0200
commit61571cae4c642918b9e49dd81b51a3a9b2e46793 (patch)
tree579421a331eec9365211f3082c2985eeb5dbde19
parentdf6c4ec7ef576e188b68ef8e64eb1f94431b5a1c (diff)
feat(newos4): world-model-ask answers with the LLM, grounded in the model (v4.6.50)
`world-model-ask` was a 10-keyword lookup: any real question returned "Unknown query. Try: modules, skills, ...". The documented example ("what changed since yesterday") compared nothing — it restated the current snapshot. - _modelDigest(): whitelisted snapshot (measured 942-965 chars on fixtures, ~3.3 KB live) + recent error sample + changesSinceLastSnapshot - _buildAskPrompt(): "answer only from this JSON, never invent numbers, name the command that would produce missing data" - ask(): connects Ollama on demand, answers via chat(), falls back to the facts path with a visible warning; --facts keeps the instant lookup - _diffSincePrev(): keeps one previous snapshot in /system/world-model.prev.json so "what changed" is a real diff (files 136 -> 140) - world_model_query tool returns the snapshot (digest or detail:full) instead of ten canned strings; no nested LLM call inside a tool call - help text now says what it does; node test modules/test/test-world-model-ask.js (11 assertions, runs the real class) Verified live v4.6.50: facts "Loaded 46 modules" | LLM "The snapshot does not contain information regarding the cause of memory growth ... use world-model-refresh" | diff "files 136 -> 140" | tool snapshot 3,334 bytes.
-rw-r--r--main.html6
-rw-r--r--modules/core/core-bundle.js131
-rw-r--r--modules/core/module-aiassist.js17
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/core/module-world-model.js114
-rw-r--r--modules/test/test-world-model-ask.js47
6 files changed, 287 insertions, 30 deletions
diff --git a/main.html b/main.html
index 636a0a1..c89d183 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.49"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.50"></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.49"></script>
+<script src="modules/core/module-loader.js?v=v4.6.50"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.49"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.50"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index f467f84..2264ef0 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -8260,7 +8260,7 @@ buildSystemPrompt() {
+ 'FS: list/read/write/delete | NVFS: nvfs-resolve | Web: search/fetch [POST=method+body] | Mod: view/edit/test/reload | Config | Skills | VaultMind | wallet-lookup | get_usage_stats\\n'
+ 'web_fetch: GET for pages, POST with method+body for JSON APIs. JS: node <file.js> or node -e "expr" (browser JS only).\n'
+ 'Other: floating, webdav, cron, vault, sandbox, agents, wpm, mindmap, memgraph, world-model, predict, guardrail, tar, secret-* — via help.\n'
- + 'predict_task: estimate duration before complex tasks. world_model_query: ask about system state, changes, health.';
+ + 'predict_task: estimate duration before complex tasks. world_model_query: read the system snapshot (modules, files, agents, telemetry, recent errors) and answer from it.';
if (profilePrompt) {
base = profilePrompt + '\n\n' + base;
}
@@ -8811,11 +8811,12 @@ buildSystemPrompt() {
type: 'function',
function: {
name: 'world_model_query',
- description: 'Query the system world model for self-awareness: modules, skills, files, agents, health, memory graph, recent changes, active projects, telemetry. Ask questions like "what changed" or "how many modules".',
+ description: 'Read the synthesized system world model (modules, files, skills, agents, memory graph, telemetry, recent changes). Returns a JSON snapshot you can reason over; `facts` additionally carries the instant keyword answer. Ask detail:"full" for the complete model (~3 KB).',
parameters: {
type: 'object',
properties: {
- question: { type: 'string', description: 'What to ask: modules, skills, files, agents, health, memory, changes, projects, telemetry, summary' }
+ question: { type: 'string', description: 'What you want to know (used for the fast facts line)' },
+ detail: { type: 'string', description: '"digest" (default) or "full"' }
},
required: ['question']
}
@@ -11197,8 +11198,14 @@ buildSystemPrompt() {
var wm = this.terminal.moduleManager.modules['WorldModelModule'];
if (!wm) return JSON.stringify({ error: 'WorldModelModule not loaded' });
if (!wm._model) return JSON.stringify({ error: 'World model not synthesized yet — run world-model-refresh' });
- var wmAnswer = wm._answerQuery(a.question || '');
- return JSON.stringify({ question: a.question, answer: wmAnswer, version: wm._model.version, synthesized: wm._model.synthesized });
+ var wmOut = {
+ question: a.question,
+ facts: wm._answerQuery(a.question || ''),
+ version: wm._model.version,
+ synthesized: wm._model.synthesized,
+ snapshot: (a.detail === 'full') ? wm._model : JSON.parse(wm._modelDigest())
+ };
+ return JSON.stringify(wmOut);
}
case 'health_check':
case 'diff_module':
@@ -17912,7 +17919,7 @@ if (typeof window !== 'undefined') {
/* === module-world-model.js === */
// module-world-model.js — Global World Model: system self-awareness synthesis engine
// Aggregates from memory graph, neural VFS, skills index, telemetry
-// Commands: world-model, world-model-summary, world-model-ask, world-model-refresh, world-model-config
+// Commands: world-model, world-model-summary, world-model-ask (LLM), world-model-refresh, world-model-config
class WorldModelModule {
constructor() {
this.commands = {};
@@ -17933,7 +17940,7 @@ class WorldModelModule {
return {
'world-model': 'world-model — Show full world model as structured output',
'world-model-summary': 'world-model-summary — One-line system self-awareness statement',
- 'world-model-ask': 'world-model-ask <question> — Query the model (e.g. "what changed since yesterday")',
+'world-model-ask': 'world-model-ask <question> — Ask the LLM about this system (grounded in the world model; --facts for the instant keyword lookup)',
'world-model-refresh': 'world-model-refresh — Force re-synthesis from all subsystems',
'world-model-config': 'world-model-config <key> <value> — Tune: refresh-interval, max-projects',
};
@@ -18285,13 +18292,30 @@ class WorldModelModule {
return summary;
}
- ask(question) {
- if (!question) return this.terminal.print('Usage: world-model-ask <question>', 'error');
+ async ask(question) {
+ if (!question) return this.terminal.print('Usage: world-model-ask <question> (--facts = fast keyword lookup)', 'error');
+ const factsOnly = /(^|\s)--facts\b/.test(question);
+ if (factsOnly) question = question.replace(/--facts\b/, '').trim();
if (!this._model) return this.terminal.print('No model — run world-model-refresh first', 'warning');
- const answer = this._answerQuery(question);
this.terminal.print(`❓ ${question}`, 'info');
- this.terminal.print(`💡 ${answer}`, 'success');
+
+ if (!factsOnly) {
+ const om = this.terminal.ollamaManager;
+ try {
+ if (om && !om.connected) { try { await om.connect(); } catch (e) {} }
+ if (om && om.connected) {
+ const answer = await om.chat(this._buildAskPrompt(question));
+ if (answer && String(answer).trim()) {
+ this.terminal.print(`💡 ${String(answer).trim()}`, 'success');
+ return;
+ }
+ }
+ } catch (e) { /* fall through to the facts path */ }
+ this.terminal.print('⚠️ LLM unavailable — showing the factual lookup instead', 'warning');
+ }
+
+ this.terminal.print(`💡 ${this._answerQuery(question)}`, 'success');
}
// Shared query routing — used by both ask() (terminal) and world_model_query (AI tool)
@@ -18313,8 +18337,12 @@ class WorldModelModule {
} else if (q.includes('memory') || q.includes('graph')) {
return `Memory Graph: ${m.memory.nodes} nodes, ${m.memory.edges} edges (${m.memory.active ? 'active' : 'inactive'})`;
} else if (q.includes('change') || q.includes('yesterday') || q.includes('recent')) {
- const recent = m.recent;
- return recent.length > 0 ? `Recent changes: ${recent.join('; ')}` : 'No recent changes detected';
+ const diff = this._diffSincePrev();
+ const recent = m.recent || [];
+ const parts = [];
+ if (diff.length) parts.push('Since the last snapshot: ' + diff.join('; '));
+ if (recent.length) parts.push('Current: ' + recent.join('; '));
+ return parts.length ? parts.join(' · ') : 'No previous snapshot yet — run world-model-refresh twice';
} else if (q.includes('project') || q.includes('work')) {
const projects = m.projects.activeProjects;
return projects.length > 0
@@ -18329,6 +18357,80 @@ class WorldModelModule {
}
}
+ // 2026-10-21: ask() used to be a keyword table only, so any real question got
+ // "Unknown query". Whitelist the fields we are willing to send to a model
+ // (never the raw model object: it is persisted alongside other VFS data).
+ _modelDigest(maxChars) {
+ const m = this._model;
+ if (!m) return '';
+ const cap = maxChars || 4000;
+ const pick = {
+ version: m.version,
+ synthesized: m.synthesized,
+ system: m.system,
+ modules: m.modules,
+ files: m.files,
+ skills: { count: m.skills && m.skills.count, skills: ((m.skills && m.skills.skills) || []).slice(0, 40) },
+ memory: m.memory,
+ agents: m.agents,
+ telemetry: m.telemetry,
+ projects: m.projects,
+ recent: (m.recent || []).slice(0, 20),
+ // 2026-10-21: the model only ever exposed a COUNT ("9 recent errors") with no detail,
+ // so neither the user nor the LLM could say what failed. Carry the last few.
+ recentErrorSample: this._errorSample ? this._errorSample() : [],
+ changesSinceLastSnapshot: this._diffSincePrev ? this._diffSincePrev() : []
+ };
+ let json = JSON.stringify(pick);
+ if (json.length > cap) json = json.slice(0, cap) + '…(digest truncated)';
+ return json;
+ }
+
+ // Last few tool/agent errors with their tool name, for the digest (bounded).
+ _errorSample() {
+ try {
+ const ds = this.terminal.moduleManager.modules['DebugSupervisorModule'];
+ const db = ds && ds._errorDB && ds._errorDB.errors;
+ if (!db) return [];
+ return Object.keys(db).slice(-5).map(k => {
+ const e = db[k] || {};
+ return String(e.tool || e.module || k).slice(0, 40) + ': ' + String(e.error || e.message || '').slice(0, 80);
+ });
+ } catch (e) { return []; }
+ }
+
+ _buildAskPrompt(question) {
+ return [
+ 'You are the newOS4 world model interface. Answer ONLY from the JSON snapshot below.',
+ 'If the snapshot does not contain the answer, say what is missing and name the command that',
+ 'would produce it (world-model-refresh, world-model, modules, test). Never invent numbers.',
+ 'Answer in 1-3 short sentences, plain text, no markdown headers.',
+ '',
+ 'SNAPSHOT: ' + this._modelDigest(),
+ '',
+ 'QUESTION: ' + String(question || '').trim()
+ ].join('\n');
+ }
+
+ _diffSincePrev() {
+ try {
+ const fs = this.terminal.fs;
+ const prev = fs && fs.fsReadJSON('/system/world-model.prev.json');
+ const cur = this._model;
+ if (!prev || !cur) return [];
+ const out = [];
+ if (prev.version && cur.version && prev.version !== cur.version) out.push(`version ${prev.version} → ${cur.version}`);
+ const fields = [['modules', 'loaded'], ['skills', 'count'], ['files', 'total'], ['agents', 'agents'], ['memory', 'nodes']];
+ fields.forEach(function (f) {
+ const a = prev[f[0]] && prev[f[0]][f[1]];
+ const b = cur[f[0]] && cur[f[0]][f[1]];
+ if (typeof a === 'number' && typeof b === 'number' && a !== b) out.push(`${f[0]} ${a} → ${b}`);
+ });
+ if (prev.synthesized) out.push('previous snapshot: ' + prev.synthesized);
+ return out;
+ } catch (e) { return []; }
+ }
+
refresh() {
this._synthesize();
this.terminal.print(`✅ World Model refreshed at ${new Date().toLocaleTimeString()}`, 'success');
@@ -18392,6 +18494,9 @@ class WorldModelModule {
_saveToVFS(fs) {
try {
if (!fs.readDir('/system')) fs.mkdir('system');
+ // 2026-10-21: keep one previous model so "what changed" can be a real diff.
+ const prevModel = fs.fsReadJSON('/system/world-model.json');
+ if (prevModel) { try { fs.fsWriteJSON('/system/world-model.prev.json', prevModel); } catch (_) {} }
// v4.5.89b fix: Clean stale temp from previous crash (P1 from review)
try { fs.deleteFile('/system/world-model.tmp.json'); } catch (_) {}
fs.fsWriteJSON('/system/world-model.tmp.json', this._model);
diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js
index f69494e..a8c0ae1 100644
--- a/modules/core/module-aiassist.js
+++ b/modules/core/module-aiassist.js
@@ -98,7 +98,7 @@ buildSystemPrompt() {
+ 'FS: list/read/write/delete | NVFS: nvfs-resolve | Web: search/fetch [POST=method+body] | Mod: view/edit/test/reload | Config | Skills | VaultMind | wallet-lookup | get_usage_stats\\n'
+ 'web_fetch: GET for pages, POST with method+body for JSON APIs. JS: node <file.js> or node -e "expr" (browser JS only).\n'
+ 'Other: floating, webdav, cron, vault, sandbox, agents, wpm, mindmap, memgraph, world-model, predict, guardrail, tar, secret-* — via help.\n'
- + 'predict_task: estimate duration before complex tasks. world_model_query: ask about system state, changes, health.';
+ + 'predict_task: estimate duration before complex tasks. world_model_query: read the system snapshot (modules, files, agents, telemetry, recent errors) and answer from it.';
if (profilePrompt) {
base = profilePrompt + '\n\n' + base;
}
@@ -649,11 +649,12 @@ buildSystemPrompt() {
type: 'function',
function: {
name: 'world_model_query',
- description: 'Query the system world model for self-awareness: modules, skills, files, agents, health, memory graph, recent changes, active projects, telemetry. Ask questions like "what changed" or "how many modules".',
+ description: 'Read the synthesized system world model (modules, files, skills, agents, memory graph, telemetry, recent changes). Returns a JSON snapshot you can reason over; `facts` additionally carries the instant keyword answer. Ask detail:"full" for the complete model (~3 KB).',
parameters: {
type: 'object',
properties: {
- question: { type: 'string', description: 'What to ask: modules, skills, files, agents, health, memory, changes, projects, telemetry, summary' }
+ question: { type: 'string', description: 'What you want to know (used for the fast facts line)' },
+ detail: { type: 'string', description: '"digest" (default) or "full"' }
},
required: ['question']
}
@@ -3035,8 +3036,14 @@ buildSystemPrompt() {
var wm = this.terminal.moduleManager.modules['WorldModelModule'];
if (!wm) return JSON.stringify({ error: 'WorldModelModule not loaded' });
if (!wm._model) return JSON.stringify({ error: 'World model not synthesized yet — run world-model-refresh' });
- var wmAnswer = wm._answerQuery(a.question || '');
- return JSON.stringify({ question: a.question, answer: wmAnswer, version: wm._model.version, synthesized: wm._model.synthesized });
+ var wmOut = {
+ question: a.question,
+ facts: wm._answerQuery(a.question || ''),
+ version: wm._model.version,
+ synthesized: wm._model.synthesized,
+ snapshot: (a.detail === 'full') ? wm._model : JSON.parse(wm._modelDigest())
+ };
+ return JSON.stringify(wmOut);
}
case 'health_check':
case 'diff_module':
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index 8330eaf..64ece7c 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.49';
+ const LATEST_VER = 'v4.6.50';
const CACHE = '?v=' + LATEST_VER;
const stored = localStorage.getItem('webx11_cache_ver');
if (stored !== LATEST_VER) {
diff --git a/modules/core/module-world-model.js b/modules/core/module-world-model.js
index 61eecfc..cbfd1fb 100644
--- a/modules/core/module-world-model.js
+++ b/modules/core/module-world-model.js
@@ -1,6 +1,6 @@
// module-world-model.js — Global World Model: system self-awareness synthesis engine
// Aggregates from memory graph, neural VFS, skills index, telemetry
-// Commands: world-model, world-model-summary, world-model-ask, world-model-refresh, world-model-config
+// Commands: world-model, world-model-summary, world-model-ask (LLM), world-model-refresh, world-model-config
class WorldModelModule {
constructor() {
this.commands = {};
@@ -21,7 +21,7 @@ class WorldModelModule {
return {
'world-model': 'world-model — Show full world model as structured output',
'world-model-summary': 'world-model-summary — One-line system self-awareness statement',
- 'world-model-ask': 'world-model-ask <question> — Query the model (e.g. "what changed since yesterday")',
+'world-model-ask': 'world-model-ask <question> — Ask the LLM about this system (grounded in the world model; --facts for the instant keyword lookup)',
'world-model-refresh': 'world-model-refresh — Force re-synthesis from all subsystems',
'world-model-config': 'world-model-config <key> <value> — Tune: refresh-interval, max-projects',
};
@@ -373,13 +373,30 @@ class WorldModelModule {
return summary;
}
- ask(question) {
- if (!question) return this.terminal.print('Usage: world-model-ask <question>', 'error');
+ async ask(question) {
+ if (!question) return this.terminal.print('Usage: world-model-ask <question> (--facts = fast keyword lookup)', 'error');
+ const factsOnly = /(^|\s)--facts\b/.test(question);
+ if (factsOnly) question = question.replace(/--facts\b/, '').trim();
if (!this._model) return this.terminal.print('No model — run world-model-refresh first', 'warning');
- const answer = this._answerQuery(question);
this.terminal.print(`❓ ${question}`, 'info');
- this.terminal.print(`💡 ${answer}`, 'success');
+
+ if (!factsOnly) {
+ const om = this.terminal.ollamaManager;
+ try {
+ if (om && !om.connected) { try { await om.connect(); } catch (e) {} }
+ if (om && om.connected) {
+ const answer = await om.chat(this._buildAskPrompt(question));
+ if (answer && String(answer).trim()) {
+ this.terminal.print(`💡 ${String(answer).trim()}`, 'success');
+ return;
+ }
+ }
+ } catch (e) { /* fall through to the facts path */ }
+ this.terminal.print('⚠️ LLM unavailable — showing the factual lookup instead', 'warning');
+ }
+
+ this.terminal.print(`💡 ${this._answerQuery(question)}`, 'success');
}
// Shared query routing — used by both ask() (terminal) and world_model_query (AI tool)
@@ -401,8 +418,12 @@ class WorldModelModule {
} else if (q.includes('memory') || q.includes('graph')) {
return `Memory Graph: ${m.memory.nodes} nodes, ${m.memory.edges} edges (${m.memory.active ? 'active' : 'inactive'})`;
} else if (q.includes('change') || q.includes('yesterday') || q.includes('recent')) {
- const recent = m.recent;
- return recent.length > 0 ? `Recent changes: ${recent.join('; ')}` : 'No recent changes detected';
+ const diff = this._diffSincePrev();
+ const recent = m.recent || [];
+ const parts = [];
+ if (diff.length) parts.push('Since the last snapshot: ' + diff.join('; '));
+ if (recent.length) parts.push('Current: ' + recent.join('; '));
+ return parts.length ? parts.join(' · ') : 'No previous snapshot yet — run world-model-refresh twice';
} else if (q.includes('project') || q.includes('work')) {
const projects = m.projects.activeProjects;
return projects.length > 0
@@ -417,6 +438,80 @@ class WorldModelModule {
}
}
+ // 2026-10-21: ask() used to be a keyword table only, so any real question got
+ // "Unknown query". Whitelist the fields we are willing to send to a model
+ // (never the raw model object: it is persisted alongside other VFS data).
+ _modelDigest(maxChars) {
+ const m = this._model;
+ if (!m) return '';
+ const cap = maxChars || 4000;
+ const pick = {
+ version: m.version,
+ synthesized: m.synthesized,
+ system: m.system,
+ modules: m.modules,
+ files: m.files,
+ skills: { count: m.skills && m.skills.count, skills: ((m.skills && m.skills.skills) || []).slice(0, 40) },
+ memory: m.memory,
+ agents: m.agents,
+ telemetry: m.telemetry,
+ projects: m.projects,
+ recent: (m.recent || []).slice(0, 20),
+ // 2026-10-21: the model only ever exposed a COUNT ("9 recent errors") with no detail,
+ // so neither the user nor the LLM could say what failed. Carry the last few.
+ recentErrorSample: this._errorSample ? this._errorSample() : [],
+ changesSinceLastSnapshot: this._diffSincePrev ? this._diffSincePrev() : []
+ };
+ let json = JSON.stringify(pick);
+ if (json.length > cap) json = json.slice(0, cap) + '…(digest truncated)';
+ return json;
+ }
+
+ // Last few tool/agent errors with their tool name, for the digest (bounded).
+ _errorSample() {
+ try {
+ const ds = this.terminal.moduleManager.modules['DebugSupervisorModule'];
+ const db = ds && ds._errorDB && ds._errorDB.errors;
+ if (!db) return [];
+ return Object.keys(db).slice(-5).map(k => {
+ const e = db[k] || {};
+ return String(e.tool || e.module || k).slice(0, 40) + ': ' + String(e.error || e.message || '').slice(0, 80);
+ });
+ } catch (e) { return []; }
+ }
+
+ _buildAskPrompt(question) {
+ return [
+ 'You are the newOS4 world model interface. Answer ONLY from the JSON snapshot below.',
+ 'If the snapshot does not contain the answer, say what is missing and name the command that',
+ 'would produce it (world-model-refresh, world-model, modules, test). Never invent numbers.',
+ 'Answer in 1-3 short sentences, plain text, no markdown headers.',
+ '',
+ 'SNAPSHOT: ' + this._modelDigest(),
+ '',
+ 'QUESTION: ' + String(question || '').trim()
+ ].join('\n');
+ }
+
+ _diffSincePrev() {
+ try {
+ const fs = this.terminal.fs;
+ const prev = fs && fs.fsReadJSON('/system/world-model.prev.json');
+ const cur = this._model;
+ if (!prev || !cur) return [];
+ const out = [];
+ if (prev.version && cur.version && prev.version !== cur.version) out.push(`version ${prev.version} → ${cur.version}`);
+ const fields = [['modules', 'loaded'], ['skills', 'count'], ['files', 'total'], ['agents', 'agents'], ['memory', 'nodes']];
+ fields.forEach(function (f) {
+ const a = prev[f[0]] && prev[f[0]][f[1]];
+ const b = cur[f[0]] && cur[f[0]][f[1]];
+ if (typeof a === 'number' && typeof b === 'number' && a !== b) out.push(`${f[0]} ${a} → ${b}`);
+ });
+ if (prev.synthesized) out.push('previous snapshot: ' + prev.synthesized);
+ return out;
+ } catch (e) { return []; }
+ }
+
refresh() {
this._synthesize();
this.terminal.print(`✅ World Model refreshed at ${new Date().toLocaleTimeString()}`, 'success');
@@ -480,6 +575,9 @@ class WorldModelModule {
_saveToVFS(fs) {
try {
if (!fs.readDir('/system')) fs.mkdir('system');
+ // 2026-10-21: keep one previous model so "what changed" can be a real diff.
+ const prevModel = fs.fsReadJSON('/system/world-model.json');
+ if (prevModel) { try { fs.fsWriteJSON('/system/world-model.prev.json', prevModel); } catch (_) {} }
// v4.5.89b fix: Clean stale temp from previous crash (P1 from review)
try { fs.deleteFile('/system/world-model.tmp.json'); } catch (_) {}
fs.fsWriteJSON('/system/world-model.tmp.json', this._model);
diff --git a/modules/test/test-world-model-ask.js b/modules/test/test-world-model-ask.js
new file mode 100644
index 0000000..4a0c21a
--- /dev/null
+++ b/modules/test/test-world-model-ask.js
@@ -0,0 +1,47 @@
+// node modules/test/test-world-model-ask.js
+// Executes the real class from module-world-model.js (no imports, no DOM at class level).
+const fs = require('fs');
+const path = require('path');
+const src = fs.readFileSync(path.join(__dirname, '../core/module-world-model.js'), 'utf8');
+const WorldModelModule = new Function(src + '\nreturn WorldModelModule;')();
+
+const wm = new WorldModelModule();
+const failures = [];
+const check = (name, cond, detail) => {
+ if (!cond) failures.push(name + (detail ? ' — ' + detail : ''));
+ console.log((cond ? 'PASS' : 'FAIL'), name, detail || '');
+};
+
+// no model yet
+check('empty model digest is empty', wm._modelDigest() === '');
+
+wm._model = {
+ version: 'v4.6.47', synthesized: '2026-10-21T20:00:00.000Z',
+ system: { health: 'ok', memory: '3 MB' },
+ modules: { loaded: 30, total: 30, modifiedCount: 0, modified: [] },
+ files: { total: 42, directories: 8, size: '12 KB' },
+ skills: { count: 23, skills: Array.from({ length: 60 }, (_, i) => 'skill-' + i) },
+ memory: { nodes: 120, edges: 200, active: true },
+ agents: { agents: 4, active: 1 },
+ telemetry: { toolLatencyDataPoints: 9, recentErrors: 0 },
+ projects: { totalTasks: 3, activeProjects: [] },
+ recent: ['Skills directory: 23 files']
+};
+
+const digest = wm._modelDigest();
+check('digest is valid JSON', (() => { try { JSON.parse(digest); return true; } catch (e) { return false; } })());
+check('digest is capped at 4000', digest.length <= 4000, digest.length + ' chars');
+check('digest keeps counts', /"count":23/.test(digest) && /"loaded":30/.test(digest));
+check('digest trims the skill list to 40', (JSON.parse(digest).skills.skills || []).length === 40,
+ String((JSON.parse(digest).skills.skills || []).length));
+
+const prompt = wm._buildAskPrompt(' why did the build break? ');
+check('prompt carries the question', /QUESTION: why did the build break\?$/.test(prompt), prompt.slice(-60));
+check('prompt carries the snapshot', prompt.indexOf('SNAPSHOT: {') > 0);
+check('prompt forbids invention', /Never invent numbers/.test(prompt));
+
+check('facts path still answers a keyword', /30 modules/.test(wm._answerQuery('how many modules')));
+check('facts path still refuses nonsense', /Unknown query/.test(wm._answerQuery('why did it break')));
+
+console.log(failures.length ? '\n' + failures.length + ' FAILED' : '\nall PASS');
+process.exit(failures.length ? 1 : 0);