aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--api/debug/dashboard.php10
-rw-r--r--api/debug/health.php17
-rw-r--r--api/debug/ingest.php91
-rw-r--r--api/debug/stats.php9
-rw-r--r--main.html6
-rw-r--r--modules/core/core-bundle.js107
-rw-r--r--modules/core/module-debug-supervisor.js67
-rw-r--r--modules/core/module-loader.js2
-rwxr-xr-xscripts/newos4-prune-debug.py92
9 files changed, 365 insertions, 36 deletions
diff --git a/api/debug/dashboard.php b/api/debug/dashboard.php
index c235ec0..74ba3af 100644
--- a/api/debug/dashboard.php
+++ b/api/debug/dashboard.php
@@ -5,7 +5,9 @@ header('Content-Type: text/html; charset=utf-8');
$errors = load_json(ERRORS_FILE);
$sessions = load_json(SESSIONS_FILE);
-$totalErrors = count($errors);
+$uniqueErrors = count($errors);
+$totalOccurrences = array_sum(array_map(fn($e) => (int)($e['count'] ?? 1), $errors));
+$lastIngest = $errors ? max(array_column($errors, 'ingested_at')) : null;
$totalSessions = count($sessions);
$byType = [];
$bySeverity = [];
@@ -91,8 +93,10 @@ if ($lastSync): ?>
<div class="grid">
<div class="card">
- <h3>Total Errors</h3>
- <div class="stat-value <?= $totalErrors > 0 ? 'error' : 'ok' ?>"><?= $totalErrors ?></div>
+ <h3>Total Occurrences</h3>
+ <div class="stat-value <?= $totalOccurrences > 0 ? 'error' : 'ok' ?>"><?= $totalOccurrences ?></div>
+ <div class="muted" style="font-size:.75em;color:var(--muted)"><?= $uniqueErrors ?> unique signatures</div>
+ <div class="muted" style="font-size:.75em;color:var(--muted)">last ingest: <?= $lastIngest ? htmlspecialchars($lastIngest) : '—' ?></div>
</div>
<div class="card">
<h3>Sessions</h3>
diff --git a/api/debug/health.php b/api/debug/health.php
index 5a0048f..883588f 100644
--- a/api/debug/health.php
+++ b/api/debug/health.php
@@ -6,11 +6,24 @@ $sessions = load_json(SESSIONS_FILE);
$lastError = !empty($errors) ? end($errors) : null;
+// Self-referential rows mean the debug pipeline is counting itself: the failure mode that
+// turned 33 real errors into 116,696 "occurrences" before 2026-10-23. Surface it instead of
+// reporting a poisoned dataset as healthy.
+$selfRef = 0;
+$occurrences = 0;
+foreach ($errors as $e) {
+ if (stripos($e['message'] ?? '', '/api/debug/') !== false) $selfRef++;
+ $occurrences += (int)($e['count'] ?? 1);
+}
+
json_response([
'success' => true,
- 'status' => 'ok',
+ 'status' => $selfRef > 0 ? 'degraded' : 'ok',
'uptime' => filemtime(__FILE__) ? (time() - filemtime(__FILE__)) : 0,
- 'total_errors' => count($errors),
+ 'total_errors' => $occurrences,
+ 'total_occurrences' => $occurrences,
+ 'unique_errors' => count($errors),
+ 'self_referential' => $selfRef,
'total_sessions' => count($sessions),
'errors_file_size_kb' => file_exists(ERRORS_FILE) ? round(filesize(ERRORS_FILE) / 1024, 1) : 0,
'sessions_file_size_kb' => file_exists(SESSIONS_FILE) ? round(filesize(SESSIONS_FILE) / 1024, 1) : 0,
diff --git a/api/debug/ingest.php b/api/debug/ingest.php
index b513593..7388bce 100644
--- a/api/debug/ingest.php
+++ b/api/debug/ingest.php
@@ -64,23 +64,51 @@ foreach ($allErrors as $idx => $existing) {
}
}
-// Ingest each error (dedup by hash)
+// Ingest each error (dedup by hash).
+// The client re-sends its whole error DB every 5 min with a CUMULATIVE count per hash.
+// Store what the client already reported for this session/hash and only add the delta,
+// otherwise every sync multiplies the occurrence count (measured 2026-10-23:
+// 33 real records had accumulated 116,696 "occurrences").
+if (!isset($sessions[$sessionId]['synced']) || !is_array($sessions[$sessionId]['synced'])) {
+ $sessions[$sessionId]['synced'] = [];
+}
+
+function norm_ts($s, $fallback) {
+ if (!is_string($s) || $s === '') return $fallback;
+ $t = strtotime($s);
+ if ($t === false) return $fallback;
+ return gmdate('c', $t); // always +00:00, never Z
+}
+
+$processed = 0; // entries handled (NOT occurrences) — the client's truncation warning uses this
foreach ($errors as $e) {
+ $processed++;
$hash = $e['hash'] ?? md5(($e['message'] ?? '') . '|' . ($e['source'] ?? '') . '|' . ($e['type'] ?? ''));
-
+ $clientCount = clamp_count($e['count'] ?? 1);
+ $prevCount = (int)($sessions[$sessionId]['synced'][$hash] ?? 0);
+ // A client whose localStorage was cleared (or a replayed payload) can present a huge
+ // cumulative count against prev=0. Cap the accepted delta per sync so one replay cannot
+ // reintroduce the 46k-style inflation.
+ if ($prevCount > $clientCount) $prevCount = 0;
+ $delta = min(5000, max(0, $clientCount - $prevCount));
+ if ($clientCount > $prevCount) {
+ $sessions[$sessionId]['synced'][$hash] = $clientCount;
+ }
+
if (isset($existingHashes[$hash])) {
- // Update existing entry
$idx = $existingHashes[$hash];
- $allErrors[$idx]['count'] += clamp_count($e['count'] ?? 1);
- $allErrors[$idx]['last_seen'] = $e['lastSeen'] ?? $now;
- $allErrors[$idx]['ingested_at'] = $now;
- if (!empty($e['stack'])) {
- $allErrors[$idx]['stack'] = substr($e['stack'], 0, 2000);
+ if ($delta > 0) {
+ $allErrors[$idx]['count'] += $delta;
+ $allErrors[$idx]['last_seen'] = norm_ts($e['lastSeen'] ?? null, $now);
+ $allErrors[$idx]['ingested_at'] = $now;
+ if (!empty($e['stack'])) {
+ $allErrors[$idx]['stack'] = substr($e['stack'], 0, 2000);
+ }
+ $ingested += $delta;
}
- $ingested++;
continue;
}
-
+
$entry = [
'hash' => $hash,
'message' => substr($e['message'] ?? '', 0, 500),
@@ -91,15 +119,38 @@ foreach ($errors as $e) {
'module' => $e['module'] ?? null,
'stack' => isset($e['stack']) ? substr($e['stack'], 0, 2000) : null,
'session_id' => $sessionId,
- 'count' => clamp_count($e['count'] ?? 1),
- 'first_seen' => $e['firstSeen'] ?? $now,
- 'last_seen' => $e['lastSeen'] ?? $now,
+ 'count' => $delta,
+ 'first_seen' => norm_ts($e['firstSeen'] ?? null, $now),
+ 'last_seen' => norm_ts($e['lastSeen'] ?? null, $now),
'ingested_at' => $now,
];
-
+
$allErrors[] = $entry;
$existingHashes[$hash] = count($allErrors) - 1;
- $ingested++;
+ $ingested += $delta;
+}
+
+// Bound the per-session counter map so sessions.json cannot grow without limit.
+if (count($sessions[$sessionId]['synced']) > 500) {
+ $sessions[$sessionId]['synced'] = array_slice($sessions[$sessionId]['synced'], -500, 500, true);
+}
+
+// Retention: drop signatures unseen for 30 days (occurrence history is in the archive).
+// A dropped hash MUST also leave every sessions[*].synced map, otherwise the next client
+// sync recreates the record with delta=0 and the retention pass never converges.
+$retentionCut = time() - 30 * 86400;
+$survivors = [];
+$allErrors = array_values(array_filter($allErrors, function ($e) use ($retentionCut, &$survivors) {
+ $t = strtotime($e['last_seen'] ?? '');
+ if ($t === false) { $survivors[$e['hash'] ?? ''] = true; return true; }
+ if ($t > $retentionCut) { $survivors[$e['hash'] ?? ''] = true; return true; }
+ return false;
+}));
+foreach ($sessions as $sid => $sinfo) {
+ if (empty($sinfo['synced'])) continue;
+ foreach (array_keys($sinfo['synced']) as $h) {
+ if (!isset($survivors[$h])) unset($sessions[$sid]['synced'][$h]);
+ }
}
// Trim to last 10,000 errors (ring buffer)
@@ -107,6 +158,15 @@ if (count($allErrors) > 10000) {
$allErrors = array_slice($allErrors, -10000);
}
+// Bound the session map itself: keep the 200 most recently seen sessions.
+if (count($sessions) > 200) {
+ uasort($sessions, fn($a, $b) => strcmp($a['last_seen'] ?? '', $b['last_seen'] ?? ''));
+ $sessions = array_slice($sessions, -200, 200, true);
+}
+
+// Write order matters: errors first, then sessions. `synced` is what suppresses a re-add, so
+// recording it before the count lands would permanently lose occurrences; the reverse loses at
+// most one batch of duplicates (harmless). Both files use flock inside save_json().
save_json(ERRORS_FILE, $allErrors);
save_json(SESSIONS_FILE, $sessions);
@@ -117,6 +177,7 @@ file_put_contents($rebuildFlag, time());
json_response([
'success' => true,
'ingested' => $ingested,
+ 'accepted' => $processed,
'session_id' => $sessionId,
'total_errors' => count($allErrors),
'total_sessions' => count($sessions),
diff --git a/api/debug/stats.php b/api/debug/stats.php
index dd0b0db..6e4feb0 100644
--- a/api/debug/stats.php
+++ b/api/debug/stats.php
@@ -37,13 +37,20 @@ foreach ($errors as $e) {
}
}
+$totalOccurrences = 0;
+foreach ($errors as $e) { $totalOccurrences += (int)($e['count'] ?? 1); }
+
usort($topErrors, fn($a, $b) => $b['count'] - $a['count']);
$topErrors = array_slice($topErrors, 0, 20);
ksort($byDay);
json_response([
'success' => true,
- 'total_errors' => $total,
+ // total_errors = OCCURRENCES (sum of counts); unique_errors = distinct signatures.
+ // Before 2026-10-23 the key held the record count while by_type held occurrences,
+ // so two numbers on the same screen disagreed by ~3,500x.
+ 'total_errors' => $totalOccurrences,
+ 'unique_errors' => $total,
'total_sessions' => count($sessions),
'by_type' => $byType,
'by_severity' => $bySeverity,
diff --git a/main.html b/main.html
index f41b12d..9701164 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.51"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.53"></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.51"></script>
+<script src="modules/core/module-loader.js?v=v4.6.53"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.51"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.53"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index 245f2bd..331e090 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -17490,9 +17490,16 @@ class MemoryGraphModule {
// v4.5.90b fix: Use configurable _decayDays (P1 from review)
const staleThreshold = this._decayDays * 24 * 60 * 60 * 1000;
var stale = [];
+ var untracked = [];
for (var name of Object.keys(index)) {
var stats = index[name] || {};
if (stats.immune) continue;
+ // A skill with no usage telemetry has never been measured. Calling it stale is how
+ // the audit came to recommend archiving the system's entire procedural memory.
+ if (stats.lastUsed === undefined && stats.useCount === undefined) {
+ untracked.push(name);
+ continue;
+ }
var useCount = stats.useCount || 0;
var lastUsed = stats.lastUsed ? new Date(stats.lastUsed).getTime() : 0;
if (useCount === 0 && (now - lastUsed > staleThreshold || lastUsed === 0)) {
@@ -17500,12 +17507,17 @@ class MemoryGraphModule {
stale.push({ name: name, daysUnused: daysUnused });
}
}
+ this._untrackedSkills = untracked;
return stale;
} catch (e) {
return [];
}
}
+ getUntrackedSkills() {
+ return Array.isArray(this._untrackedSkills) ? this._untrackedSkills : [];
+ }
+
// v4.5.89: Configure decay policy
decayConfig(args) {
if (args.length < 2) {
@@ -18550,14 +18562,28 @@ class WorldModelModule {
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);
- const tmp = fs.fsReadJSON('/system/world-model.tmp.json');
- if (tmp) {
- fs.fsWriteJSON('/system/world-model.json', tmp);
- fs.deleteFile('/system/world-model.tmp.json');
- } else {
- console.error('[WorldModel] Save failed: temp file corrupt after write');
+ // Atomic + verified: never promote a write we did not read back byte-for-byte.
+ // Measured 2026-10-23: the old block promoted /system/world-model.json even when every
+ // write returned false, and logged nothing — that is how a graph got replaced by a
+ // partial model. fs.fsWriteJSON() returns a boolean; ignoring it hides quota failures.
+ const payload = JSON.stringify(this._model);
+ const wrote = fs.writeFile('/system/world-model.tmp.json', payload);
+ if (!wrote) {
+ console.error('[WorldModel] Save failed: write returned false (storage quota?)');
+ return;
+ }
+ const back = fs.readFile('/system/world-model.tmp.json');
+ if (back !== payload) {
+ console.error('[WorldModel] Save failed: read-back mismatch ('
+ + (back ? back.length : 0) + '/' + payload.length + ' bytes) — keeping previous model');
+ return;
+ }
+ const promoted = fs.writeFile('/system/world-model.json', back);
+ if (!promoted) {
+ console.error('[WorldModel] Save failed: promote returned false');
+ return;
}
+ fs.deleteFile('/system/world-model.tmp.json');
} catch (e) {
console.error('[WorldModel] Save error:', e.message);
}
@@ -20009,6 +20035,8 @@ class DebugSupervisorModule {
'debug-health': 'debug-health — Check supervisor module health status',
'debug-sync': 'debug-sync [url] — Sync errors to backend API (or set sync URL)',
'debug-sync-url': 'debug-sync-url [url] — View or set the backend sync endpoint',
+ 'feedback': 'feedback <message> — Send feedback to the WebX11 maintenance agent',
+ 'feedback-inbox': 'feedback-inbox — Read replies from the maintenance agent (admin session)',
};
}
@@ -20021,6 +20049,8 @@ class DebugSupervisorModule {
this.commands['debug-health'] = () => this._cmdHealth();
this.commands['debug-sync'] = (args) => this._cmdSync(args);
this.commands['debug-sync-url'] = (args) => this._cmdSyncUrl(args);
+ this.commands['feedback'] = (args) => this._cmdFeedback(args);
+ this.commands['feedback-inbox'] = () => this._cmdFeedbackInbox();
}
seed() {
@@ -20029,6 +20059,9 @@ class DebugSupervisorModule {
init() {
this._loadDB();
+ try {
+ this._syncedCounts = JSON.parse(localStorage.getItem('webx11_debug_synced') || '{}');
+ } catch (_e) { this._syncedCounts = {}; }
this._installInterceptors();
// Start periodic sync (every 5 minutes)
this._syncInterval = setInterval(() => this._syncToBackend(), 300000);
@@ -20297,6 +20330,12 @@ class DebugSupervisorModule {
_maybeAutoFileInner(e) {
try { if (localStorage.getItem('webx11_autoreport') === 'off') return; } catch (_e) {}
+ // Aborts/timeouts are the client's own cancellation, not application bugs, and the debug
+ // pipeline's own requests must never be filed as reports (self-referential loop).
+ // Measured 2026-10-21..23: 2 of 5 auto-filed reports were "user aborted a request".
+ if (/(user aborted|aborted without reason|signal timed out|AbortError|TimeoutError)/i.test(String(e.message || ''))) return;
+ if (/\/api\/debug\//.test(String(e.source || '')) || /\/api\/debug\//.test(String(e.message || ''))) return;
+
const today = new Date().toISOString().slice(0, 10);
let state = { day: today, count: 0, hashes: [] };
try {
@@ -20732,7 +20771,12 @@ class DebugSupervisorModule {
async _syncToBackend() {
if (!this._healthy) return;
const db = this._errorDB.errors;
- const keys = Object.keys(db);
+ const allKeys = Object.keys(db);
+ if (allKeys.length === 0) return;
+ // Only send hashes whose cumulative count changed since the last successful sync.
+ // The server counts deltas per (session, hash); re-sending unchanged rows is what
+ // multiplied one error into 46,133 occurrences before 2026-10-23.
+ const keys = allKeys.filter(k => (this._syncedCounts[k] || 0) !== db[k].count);
if (keys.length === 0) return;
const payload = {
@@ -20767,12 +20811,14 @@ class DebugSupervisorModule {
const result = await resp.json();
if (result.success) {
const sent = keys.length;
- const accepted = result.ingested || 0;
+ const accepted = result.accepted ?? result.ingested ?? 0;
if (accepted < sent) {
console.warn('[DebugSupervisor] Sync truncated: sent ' + sent + ' errors, server accepted ' + accepted + ' (max ' + (result.total_errors || '?') + ' total)');
} else {
console.log('[DebugSupervisor] Synced ' + accepted + ' errors to backend');
}
+ keys.forEach(k => { this._syncedCounts[k] = db[k].count; });
+ try { localStorage.setItem('webx11_debug_synced', JSON.stringify(this._syncedCounts)); } catch (_e) {}
this._lastSyncOk = Date.now();
return true;
}
@@ -20785,6 +20831,49 @@ class DebugSupervisorModule {
}
}
+ _feedbackThread() {
+ let t = null;
+ try { t = localStorage.getItem('webx11_feedback_thread'); } catch (_e) {}
+ if (!t) {
+ t = 'thr-' + Math.random().toString(36).slice(2, 10);
+ try { localStorage.setItem('webx11_feedback_thread', t); } catch (_e) {}
+ }
+ return t;
+ }
+
+ _cmdFeedback(args) {
+ const msg = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim();
+ if (!msg) { this.terminal.print('usage: feedback <message>', 'error'); return; }
+ const payload = {
+ title: msg.slice(0, 120),
+ body: msg.slice(0, 4000),
+ thread: this._feedbackThread(),
+ kind: 'maintenance',
+ diagnostics: { version: window.NEWOS4_VERSION || '?', url: location.href, auto: false },
+ };
+ this.terminal.print('📨 Sending feedback…', '');
+ fetch('/spaces/newos4/api/debug/report.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ }).then(r => r.json()).then(j => {
+ if (j && j.success) this.terminal.print('✅ Sent: ' + (j.id || 'ok') + (j.duplicate ? ' (merged)' : ''), 'success');
+ else this.terminal.print('❌ ' + ((j && j.error) || 'send failed'), 'error');
+ }).catch(e => this.terminal.print('❌ ' + e.message, 'error'));
+ }
+
+ _cmdFeedbackInbox() {
+ this.terminal.print('📬 Fetching replies…', '');
+ fetch('/spaces/newos4/api/debug/agent-replies.php', { credentials: 'same-origin' })
+ .then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status + ' (admin session required)')))
+ .then(j => {
+ const list = (j && j.replies) || [];
+ if (!list.length) { this.terminal.print('(no replies yet)', ''); return; }
+ list.slice(-10).forEach(r => this.terminal.print('🤖 ' + (r.created_at || '') + ' ' + r.body, ''));
+ })
+ .catch(e => this.terminal.print('❌ ' + e.message, 'error'));
+ }
+
_cmdSync(args) {
const url = (args && args[0]) ? args[0] : null;
if (url) {
diff --git a/modules/core/module-debug-supervisor.js b/modules/core/module-debug-supervisor.js
index 959d162..485f4e2 100644
--- a/modules/core/module-debug-supervisor.js
+++ b/modules/core/module-debug-supervisor.js
@@ -41,6 +41,8 @@ class DebugSupervisorModule {
'debug-health': 'debug-health — Check supervisor module health status',
'debug-sync': 'debug-sync [url] — Sync errors to backend API (or set sync URL)',
'debug-sync-url': 'debug-sync-url [url] — View or set the backend sync endpoint',
+ 'feedback': 'feedback <message> — Send feedback to the WebX11 maintenance agent',
+ 'feedback-inbox': 'feedback-inbox — Read replies from the maintenance agent (admin session)',
};
}
@@ -53,6 +55,8 @@ class DebugSupervisorModule {
this.commands['debug-health'] = () => this._cmdHealth();
this.commands['debug-sync'] = (args) => this._cmdSync(args);
this.commands['debug-sync-url'] = (args) => this._cmdSyncUrl(args);
+ this.commands['feedback'] = (args) => this._cmdFeedback(args);
+ this.commands['feedback-inbox'] = () => this._cmdFeedbackInbox();
}
seed() {
@@ -61,6 +65,9 @@ class DebugSupervisorModule {
init() {
this._loadDB();
+ try {
+ this._syncedCounts = JSON.parse(localStorage.getItem('webx11_debug_synced') || '{}');
+ } catch (_e) { this._syncedCounts = {}; }
this._installInterceptors();
// Start periodic sync (every 5 minutes)
this._syncInterval = setInterval(() => this._syncToBackend(), 300000);
@@ -329,6 +336,12 @@ class DebugSupervisorModule {
_maybeAutoFileInner(e) {
try { if (localStorage.getItem('webx11_autoreport') === 'off') return; } catch (_e) {}
+ // Aborts/timeouts are the client's own cancellation, not application bugs, and the debug
+ // pipeline's own requests must never be filed as reports (self-referential loop).
+ // Measured 2026-10-21..23: 2 of 5 auto-filed reports were "user aborted a request".
+ if (/(user aborted|aborted without reason|signal timed out|AbortError|TimeoutError)/i.test(String(e.message || ''))) return;
+ if (/\/api\/debug\//.test(String(e.source || '')) || /\/api\/debug\//.test(String(e.message || ''))) return;
+
const today = new Date().toISOString().slice(0, 10);
let state = { day: today, count: 0, hashes: [] };
try {
@@ -764,7 +777,12 @@ class DebugSupervisorModule {
async _syncToBackend() {
if (!this._healthy) return;
const db = this._errorDB.errors;
- const keys = Object.keys(db);
+ const allKeys = Object.keys(db);
+ if (allKeys.length === 0) return;
+ // Only send hashes whose cumulative count changed since the last successful sync.
+ // The server counts deltas per (session, hash); re-sending unchanged rows is what
+ // multiplied one error into 46,133 occurrences before 2026-10-23.
+ const keys = allKeys.filter(k => (this._syncedCounts[k] || 0) !== db[k].count);
if (keys.length === 0) return;
const payload = {
@@ -799,12 +817,14 @@ class DebugSupervisorModule {
const result = await resp.json();
if (result.success) {
const sent = keys.length;
- const accepted = result.ingested || 0;
+ const accepted = result.accepted ?? result.ingested ?? 0;
if (accepted < sent) {
console.warn('[DebugSupervisor] Sync truncated: sent ' + sent + ' errors, server accepted ' + accepted + ' (max ' + (result.total_errors || '?') + ' total)');
} else {
console.log('[DebugSupervisor] Synced ' + accepted + ' errors to backend');
}
+ keys.forEach(k => { this._syncedCounts[k] = db[k].count; });
+ try { localStorage.setItem('webx11_debug_synced', JSON.stringify(this._syncedCounts)); } catch (_e) {}
this._lastSyncOk = Date.now();
return true;
}
@@ -817,6 +837,49 @@ class DebugSupervisorModule {
}
}
+ _feedbackThread() {
+ let t = null;
+ try { t = localStorage.getItem('webx11_feedback_thread'); } catch (_e) {}
+ if (!t) {
+ t = 'thr-' + Math.random().toString(36).slice(2, 10);
+ try { localStorage.setItem('webx11_feedback_thread', t); } catch (_e) {}
+ }
+ return t;
+ }
+
+ _cmdFeedback(args) {
+ const msg = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim();
+ if (!msg) { this.terminal.print('usage: feedback <message>', 'error'); return; }
+ const payload = {
+ title: msg.slice(0, 120),
+ body: msg.slice(0, 4000),
+ thread: this._feedbackThread(),
+ kind: 'maintenance',
+ diagnostics: { version: window.NEWOS4_VERSION || '?', url: location.href, auto: false },
+ };
+ this.terminal.print('📨 Sending feedback…', '');
+ fetch('/spaces/newos4/api/debug/report.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ }).then(r => r.json()).then(j => {
+ if (j && j.success) this.terminal.print('✅ Sent: ' + (j.id || 'ok') + (j.duplicate ? ' (merged)' : ''), 'success');
+ else this.terminal.print('❌ ' + ((j && j.error) || 'send failed'), 'error');
+ }).catch(e => this.terminal.print('❌ ' + e.message, 'error'));
+ }
+
+ _cmdFeedbackInbox() {
+ this.terminal.print('📬 Fetching replies…', '');
+ fetch('/spaces/newos4/api/debug/agent-replies.php', { credentials: 'same-origin' })
+ .then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status + ' (admin session required)')))
+ .then(j => {
+ const list = (j && j.replies) || [];
+ if (!list.length) { this.terminal.print('(no replies yet)', ''); return; }
+ list.slice(-10).forEach(r => this.terminal.print('🤖 ' + (r.created_at || '') + ' ' + r.body, ''));
+ })
+ .catch(e => this.terminal.print('❌ ' + e.message, 'error'));
+ }
+
_cmdSync(args) {
const url = (args && args[0]) ? args[0] : null;
if (url) {
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index 46aad17..f22576a 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.51';
+ const LATEST_VER = 'v4.6.53';
const CACHE = '?v=' + LATEST_VER;
const stored = localStorage.getItem('webx11_cache_ver');
if (stored !== LATEST_VER) {
diff --git a/scripts/newos4-prune-debug.py b/scripts/newos4-prune-debug.py
new file mode 100755
index 0000000..7fe0fcb
--- /dev/null
+++ b/scripts/newos4-prune-debug.py
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+"""Prune self-referential / stale debug rows from newOS4 telemetry.
+
+Read-only unless --apply. Coordinates with PHP via the SAME flock on errors.json
+(ingest.php opens it 'c' + LOCK_EX), and always archives before rewriting.
+"""
+import argparse, fcntl, json, os, re, time
+
+ROOT = '/var/www/webx11.duckdns.org/spaces/newos4'
+DEBUG = os.path.join(ROOT, 'data', 'debug')
+ERRORS = os.path.join(DEBUG, 'errors.json')
+ARCHIVE = os.path.join(DEBUG, 'archive')
+SELF = re.compile(r'/api/debug/(ingest|report)\.php', re.I)
+
+
+def iso_to_epoch(s):
+ """Parse '2026-10-20T16:57:31.589Z' or '...+00:00'. Returns None when unparseable."""
+ if not s:
+ return None
+ t = str(s).strip().replace('Z', '+00:00')
+ if '.' in t:
+ head, rest = t.split('.', 1)
+ frac = rest[:6].ljust(6, '0')
+ tail = rest[6:]
+ t = f'{head}.{frac}{tail}'
+ try:
+ import datetime
+ d = datetime.datetime.fromisoformat(t)
+ if d.tzinfo is None:
+ d = d.replace(tzinfo=datetime.timezone.utc)
+ return d.timestamp()
+ except Exception:
+ return None
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument('--apply', action='store_true')
+ ap.add_argument('--max-age-days', type=int, default=30)
+ a = ap.parse_args()
+ cutoff = time.time() - a.max_age_days * 86400
+
+ fp = open(ERRORS, 'r+', encoding='utf-8')
+ fcntl.flock(fp, fcntl.LOCK_EX)
+ try:
+ errors = json.load(fp) or []
+ keep, drop = [], []
+ for e in errors:
+ msg = str(e.get('message', ''))
+ age_ref = iso_to_epoch(e.get('last_seen')) or iso_to_epoch(e.get('ingested_at'))
+ if SELF.search(msg):
+ drop.append((e, 'self-referential'))
+ elif age_ref is not None and age_ref < cutoff:
+ drop.append((e, f'older than {a.max_age_days}d'))
+ else:
+ keep.append(e)
+
+ print(f'records={len(errors)} keep={len(keep)} drop={len(drop)}')
+ reasons = {}
+ for e, why in drop:
+ reasons[why] = reasons.get(why, 0) + 1
+ for why, n in sorted(reasons.items()):
+ print(f' drop {n:>2} reason={why}')
+ for e, why in drop[:8]:
+ print(f" count={e.get('count')} {why}: {str(e.get('message'))[:80]}")
+ print(f"occurrences before={sum(int(e.get('count') or 1) for e in errors)} "
+ f"after={sum(int(e.get('count') or 1) for e in keep)}")
+
+ if not a.apply:
+ print('dry-run; re-run with --apply to write')
+ return
+
+ os.makedirs(ARCHIVE, exist_ok=True)
+ archive = os.path.join(ARCHIVE, f'errors-{time.strftime("%Y%m%d_%H%M%S")}.json')
+ with open(archive, 'w', encoding='utf-8') as af:
+ json.dump(errors, af, indent=2)
+ os.chmod(archive, 0o644)
+
+ fp.seek(0)
+ fp.truncate()
+ json.dump(keep, fp, indent=2)
+ fp.flush()
+ os.fsync(fp.fileno())
+ open(os.path.join(DEBUG, '.rebuild_clusters'), 'w').write(str(int(time.time())))
+ print(f'applied: {archive} written, errors.json rewritten, cluster rebuild flagged')
+ finally:
+ fcntl.flock(fp, fcntl.LOCK_UN)
+ fp.close()
+
+
+if __name__ == '__main__':
+ main()